diff --git a/change-beta/@azure-communication-react-6987416e-10fd-4a7c-9ba8-358fdf373568.json b/change-beta/@azure-communication-react-6987416e-10fd-4a7c-9ba8-358fdf373568.json new file mode 100644 index 00000000000..e2cd5ce39db --- /dev/null +++ b/change-beta/@azure-communication-react-6987416e-10fd-4a7c-9ba8-358fdf373568.json @@ -0,0 +1,9 @@ +{ + "type": "prerelease", + "area": "feature", + "workstream": "Breakout rooms", + "comment": "Promote breakout rooms feature to GA", + "packageName": "@azure/communication-react", + "email": "79475487+mgamis-msft@users.noreply.github.com", + "dependentChangeType": "patch" +} diff --git a/common/config/babel/features.js b/common/config/babel/features.js index 94ce847e67e..b727a077b8d 100644 --- a/common/config/babel/features.js +++ b/common/config/babel/features.js @@ -71,14 +71,14 @@ module.exports = { "teams-identity-support-beta", // feature for tracking the callParticipantsLocator "call-participants-locator", - // feature for breakout rooms - "breakout-rooms", ], stable: [ // Demo feature. Used in live-documentation of conditional compilation. // Do not use in production code. "stabilizedDemo", - // Feature for forbid/permit Teams meeting/group call attendee' audio/video access - "media-access" + // Feature for forbid/permit remote participants audio/video access + "media-access", + // Feature for breakout rooms + "breakout-rooms" ] } diff --git a/packages/calling-component-bindings/src/handlers/createHandlers.ts b/packages/calling-component-bindings/src/handlers/createHandlers.ts index f74bbf109ae..b457e0f184c 100644 --- a/packages/calling-component-bindings/src/handlers/createHandlers.ts +++ b/packages/calling-component-bindings/src/handlers/createHandlers.ts @@ -70,12 +70,6 @@ export type CreateDefaultCallingHandlers = ( */ export const createDefaultCallingHandlers: CreateDefaultCallingHandlers = memoizeOne((...args) => { const [callClient, callAgent, deviceManager, call, options] = args; - /* @conditional-compile-remove(breakout-rooms) */ - const callState = call?.id ? callClient.getState().calls[call?.id] : undefined; - /* @conditional-compile-remove(breakout-rooms) */ - const breakoutRoomOriginCallId = callState?.breakoutRooms?.breakoutRoomOriginCallId; - /* @conditional-compile-remove(breakout-rooms) */ - const breakoutRoomOriginCall = callAgent?.calls.find((call) => call.id === breakoutRoomOriginCallId); const commonCallingHandlers = createDefaultCommonCallingHandlers(callClient, deviceManager, call, options); return { ...commonCallingHandlers, @@ -116,11 +110,7 @@ export const createDefaultCallingHandlers: CreateDefaultCallingHandlers = memoiz if (incomingCall) { await incomingCall.reject(); } - }, - /* @conditional-compile-remove(breakout-rooms) */ - onHangUp: breakoutRoomOriginCall - ? async () => breakoutRoomOriginCall.hangUp().then(() => commonCallingHandlers.onHangUp()) - : commonCallingHandlers.onHangUp + } }; }); diff --git a/packages/calling-component-bindings/src/notificationStackSelector.ts b/packages/calling-component-bindings/src/notificationStackSelector.ts index 9a9643002d7..8e833caa4b7 100644 --- a/packages/calling-component-bindings/src/notificationStackSelector.ts +++ b/packages/calling-component-bindings/src/notificationStackSelector.ts @@ -254,6 +254,13 @@ export const notificationStackSelector: NotificationStackSelector = createSelect }); } /* @conditional-compile-remove(breakout-rooms) */ + if (latestNotifications['assignedBreakoutRoomClosed']) { + activeNotifications.push({ + type: 'assignedBreakoutRoomClosed', + timestamp: latestNotifications['assignedBreakoutRoomClosed'].timestamp + }); + } + /* @conditional-compile-remove(breakout-rooms) */ if (latestNotifications['breakoutRoomJoined']) { activeNotifications.push({ type: 'breakoutRoomJoined', diff --git a/packages/calling-stateful-client/src/BreakoutRoomsSubscriber.ts b/packages/calling-stateful-client/src/BreakoutRoomsSubscriber.ts index eecba1d069c..fb23b121844 100644 --- a/packages/calling-stateful-client/src/BreakoutRoomsSubscriber.ts +++ b/packages/calling-stateful-client/src/BreakoutRoomsSubscriber.ts @@ -82,39 +82,52 @@ export class BreakoutRoomsSubscriber { return; } + // TODO: Fix the condition in this if statement to check for different breakout room ID instead of the display name + // when Calling SDK fixes the breakout room call id to be correct if ( breakoutRoom.state === 'open' && - currentAssignedBreakoutRoom?.state === 'open' && - currentAssignedBreakoutRoom?.call?.id !== breakoutRoom.call?.id + callState.breakoutRooms?.breakoutRoomDisplayName && + callState.breakoutRooms.breakoutRoomDisplayName !== breakoutRoom.displayName ) { - if ( - !this._context.getState().latestNotifications['assignedBreakoutRoomOpened'] && - !this._context.getState().latestNotifications['assignedBreakoutRoomOpenedPromptJoin'] - ) { - this._context.setLatestNotification(this._callIdRef.callId, { - target: 'assignedBreakoutRoomChanged', - timestamp: new Date(Date.now()) - }); - } - } else if (breakoutRoom.state === 'open') { - if (!this._context.getState().latestNotifications['assignedBreakoutRoomChanged']) { - const target: NotificationTarget = - breakoutRoom.autoMoveParticipantToBreakoutRoom === false - ? 'assignedBreakoutRoomOpenedPromptJoin' - : 'assignedBreakoutRoomOpened'; - this._context.setLatestNotification(this._callIdRef.callId, { target, timestamp: new Date(Date.now()) }); - } - } else if (breakoutRoom.state === 'closed' && currentAssignedBreakoutRoom?.state === 'closed') { + this._context.setLatestNotification(this._callIdRef.callId, { + target: 'assignedBreakoutRoomChanged', + timestamp: new Date(Date.now()) + }); + } else if ( + breakoutRoom.state === 'open' && + !callState?.breakoutRooms?.breakoutRoomSettings && + this._context.getOpenBreakoutRoom() === undefined + ) { + const target: NotificationTarget = + breakoutRoom.autoMoveParticipantToBreakoutRoom === false + ? 'assignedBreakoutRoomOpenedPromptJoin' + : 'assignedBreakoutRoomOpened'; + this._context.setLatestNotification(this._callIdRef.callId, { target, timestamp: new Date(Date.now()) }); + } else if (breakoutRoom.state === 'closed' && currentAssignedBreakoutRoom?.state === 'open') { // This scenario covers the case where the breakout room is opened but then closed before the user joins. this._context.deleteLatestNotification(this._callIdRef.callId, 'assignedBreakoutRoomOpened'); this._context.deleteLatestNotification(this._callIdRef.callId, 'assignedBreakoutRoomOpenedPromptJoin'); this._context.deleteLatestNotification(this._callIdRef.callId, 'assignedBreakoutRoomChanged'); - } else if (breakoutRoom.state === 'closed' && currentAssignedBreakoutRoom?.call?.id) { - // This scenario covers the case where the breakout room is changed to a closed breakout room. - this._context.deleteLatestNotification(currentAssignedBreakoutRoom.call.id, 'breakoutRoomJoined'); - this._context.deleteLatestNotification(currentAssignedBreakoutRoom.call.id, 'breakoutRoomClosingSoon'); + this._context.deleteLatestNotification(this._callIdRef.callId, 'breakoutRoomJoined'); + } else if (breakoutRoom.state === 'closed') { + // This scenario covers the case where the breakout room is closed + this._context.deleteLatestNotification(this._callIdRef.callId, 'assignedBreakoutRoomOpened'); + this._context.deleteLatestNotification(this._callIdRef.callId, 'assignedBreakoutRoomOpenedPromptJoin'); + this._context.deleteLatestNotification(this._callIdRef.callId, 'assignedBreakoutRoomChanged'); + this._context.deleteLatestNotification(this._callIdRef.callId, 'breakoutRoomJoined'); + this._context.deleteLatestNotification(this._callIdRef.callId, 'breakoutRoomClosingSoon'); clearTimeout(this._breakoutRoomClosingSoonTimeoutId); + const openBreakoutRoomId = this._context.getOpenBreakoutRoom(); + if (openBreakoutRoomId && this._context.getState().calls[openBreakoutRoomId]) { + // Show notification that the assigned breakout room was closed if the user is in that breakout room. + this._context.setLatestNotification(this._callIdRef.callId, { + target: 'assignedBreakoutRoomClosed', + timestamp: new Date(Date.now()) + }); + } + this._context.deleteOpenBreakoutRoom(); } + this._context.setAssignedBreakoutRoom(this._callIdRef.callId, breakoutRoom); }; @@ -130,6 +143,8 @@ export class BreakoutRoomsSubscriber { timestamp: new Date(Date.now()) }); + this._context.setOpenBreakoutRoom(call.id); + // If assigned breakout room has a display name, set the display name for its call state. const assignedBreakoutRoomDisplayName = this._context.getState().calls[this._callIdRef.callId]?.breakoutRooms?.assignedBreakoutRoom?.displayName; diff --git a/packages/calling-stateful-client/src/CallClientState.ts b/packages/calling-stateful-client/src/CallClientState.ts index 68d9a1b0a78..d8fe4d109f5 100644 --- a/packages/calling-stateful-client/src/CallClientState.ts +++ b/packages/calling-stateful-client/src/CallClientState.ts @@ -260,9 +260,13 @@ export interface SpotlightState { * @public */ export interface BreakoutRoomsState { + /** Breakout room assigned to local user in call */ assignedBreakoutRoom?: BreakoutRoom; + /** Breakout room settings of call. This is defined when call is a breakout room. */ breakoutRoomSettings?: BreakoutRoomsSettings; + /** Origin call id of breakout room. This is defined when call is a breakout room. */ breakoutRoomOriginCallId?: string; + /** Display name of breakout room. This is defined when call is a breakout room. */ breakoutRoomDisplayName?: string; } @@ -1206,6 +1210,7 @@ export type NotificationTarget = | /* @conditional-compile-remove(breakout-rooms) */ 'assignedBreakoutRoomOpened' | /* @conditional-compile-remove(breakout-rooms) */ 'assignedBreakoutRoomOpenedPromptJoin' | /* @conditional-compile-remove(breakout-rooms) */ 'assignedBreakoutRoomChanged' + | /* @conditional-compile-remove(breakout-rooms) */ 'assignedBreakoutRoomClosed' | /* @conditional-compile-remove(breakout-rooms) */ 'breakoutRoomJoined' | /* @conditional-compile-remove(breakout-rooms) */ 'breakoutRoomClosingSoon' | 'capabilityTurnVideoOnPresent' diff --git a/packages/calling-stateful-client/src/CallContext.ts b/packages/calling-stateful-client/src/CallContext.ts index 456e2d2e622..2d4155243a6 100644 --- a/packages/calling-stateful-client/src/CallContext.ts +++ b/packages/calling-stateful-client/src/CallContext.ts @@ -95,7 +95,9 @@ export class CallContext { private _atomicId: number; private _callIdHistory: CallIdHistory = new CallIdHistory(); private _timeOutId: { [key: string]: ReturnType } = {}; - private _latestCallIdsThatPushedNotifications: Partial> = {}; + private _latestCallIdOfNotification: Partial> = {}; + /* @conditional-compile-remove(breakout-rooms) */ + private _openBreakoutRoom: string | undefined; constructor(userId: CommunicationIdentifierKind, maxListeners = 50) { this._logger = createClientLogger('communication-react:calling-context'); @@ -257,6 +259,20 @@ export class CallContext { call.breakoutRooms?.breakoutRoomOriginCallId === newCallId; } }); + + /* @conditional-compile-remove(breakout-rooms) */ + // Update call ids in latestCallIdsThatPushedNotifications + Object.keys(this._latestCallIdOfNotification).forEach((key: string) => { + if (this._latestCallIdOfNotification[key as NotificationTarget] === oldCallId) { + this._latestCallIdOfNotification[key as NotificationTarget] = newCallId; + } + }); + + /* @conditional-compile-remove(breakout-rooms) */ + // Update the open breakout room call id if it matches the old call id + if (this._openBreakoutRoom === oldCallId) { + this._openBreakoutRoom = newCallId; + } }); } @@ -776,6 +792,21 @@ export class CallContext { }); } + /* @conditional-compile-remove(breakout-rooms) */ + public setOpenBreakoutRoom(callId: string): void { + this._openBreakoutRoom = callId; + } + + /* @conditional-compile-remove(breakout-rooms) */ + public deleteOpenBreakoutRoom(): void { + this._openBreakoutRoom = undefined; + } + + /* @conditional-compile-remove(breakout-rooms) */ + public getOpenBreakoutRoom(): string | undefined { + return this._openBreakoutRoom; + } + public setCallScreenShareParticipant(callId: string, participantKey: string | undefined): void { this.modifyState((draft: CallClientState) => { const call = draft.calls[this._callIdHistory.latestCallId(callId)]; @@ -1510,20 +1541,18 @@ export class CallContext { } public setLatestNotification(callId: string, notification: CallNotification): void { - this._latestCallIdsThatPushedNotifications[notification.target] = callId; + this._latestCallIdOfNotification[notification.target] = callId; this.modifyState((draft: CallClientState) => { draft.latestNotifications[notification.target] = notification; }); } - public deleteLatestNotification(callId: string, notificationTarget: NotificationTarget): void { - let callIdToPushLatestNotification = this._latestCallIdsThatPushedNotifications[notificationTarget]; - callIdToPushLatestNotification = callIdToPushLatestNotification - ? this._callIdHistory.latestCallId(callIdToPushLatestNotification) - : undefined; - // Only delete the notification if the call that pushed the notification is the same as the call that is trying - // to delete it. - if (callIdToPushLatestNotification !== callId) { + public deleteLatestNotification(callId: string | undefined, notificationTarget: NotificationTarget): void { + const callIdOfNotification = this._latestCallIdOfNotification[notificationTarget]; + + // Only delete the notification if the call that pushed the notification is the same as the callId specified if it + // is provided + if (callId && callIdOfNotification !== callId) { return; } diff --git a/packages/calling-stateful-client/src/CallSubscriber.ts b/packages/calling-stateful-client/src/CallSubscriber.ts index 64e14f771e0..2296d91ad40 100644 --- a/packages/calling-stateful-client/src/CallSubscriber.ts +++ b/packages/calling-stateful-client/src/CallSubscriber.ts @@ -124,6 +124,9 @@ export class CallSubscriber { this._context, this._call.feature(Features.Spotlight) ); + + // Clear assigned breakout room closed notification for this call. + this._context.deleteLatestNotification(undefined, 'assignedBreakoutRoomClosed'); /* @conditional-compile-remove(breakout-rooms) */ this._breakoutRoomsSubscriber = new BreakoutRoomsSubscriber( this._callIdRef, diff --git a/packages/communication-react/review/beta/communication-react.api.md b/packages/communication-react/review/beta/communication-react.api.md index c54f9e4f19f..6e726d07c38 100644 --- a/packages/communication-react/review/beta/communication-react.api.md +++ b/packages/communication-react/review/beta/communication-react.api.md @@ -364,13 +364,9 @@ export interface BlockedMessage extends MessageCommon { // @public export interface BreakoutRoomsState { - // (undocumented) assignedBreakoutRoom?: BreakoutRoom; - // (undocumented) breakoutRoomDisplayName?: string; - // (undocumented) breakoutRoomOriginCallId?: string; - // (undocumented) breakoutRoomSettings?: BreakoutRoomsSettings; } @@ -805,7 +801,7 @@ export type CallCompositeOptions = { }; // @public -export type CallCompositePage = 'accessDeniedTeamsMeeting' | 'call' | 'configuration' | 'hold' | 'joinCallFailedDueToNoNetwork' | 'leftCall' | 'leaving' | 'lobby' | 'removedFromCall' | /* @conditional-compile-remove(unsupported-browser) */ 'unsupportedEnvironment' | 'transferring' | 'badRequest'; +export type CallCompositePage = 'accessDeniedTeamsMeeting' | 'call' | 'configuration' | 'hold' | 'joinCallFailedDueToNoNetwork' | 'leftCall' | 'leaving' | 'lobby' | 'removedFromCall' | /* @conditional-compile-remove(unsupported-browser) */ 'unsupportedEnvironment' | 'transferring' | 'badRequest' | /* @conditional-compile-remove(breakout-rooms) */ 'returningFromBreakoutRoom'; // @public export interface CallCompositeProps extends BaseCompositeProps { @@ -2980,6 +2976,7 @@ export const DEFAULT_COMPONENT_ICONS: { NotificationBarBreakoutRoomChanged: React_2.JSX.Element; NotificationBarBreakoutRoomJoined: React_2.JSX.Element; NotificationBarBreakoutRoomClosingSoon: React_2.JSX.Element; + NotificationBarBreakoutRoomClosed: React_2.JSX.Element; HorizontalGalleryLeftButton: React_2.JSX.Element; HorizontalGalleryRightButton: React_2.JSX.Element; MessageDelivered: React_2.JSX.Element; @@ -3186,6 +3183,7 @@ export const DEFAULT_COMPOSITE_ICONS: { NotificationBarBreakoutRoomChanged: React_2.JSX.Element; NotificationBarBreakoutRoomJoined: React_2.JSX.Element; NotificationBarBreakoutRoomClosingSoon: React_2.JSX.Element; + NotificationBarBreakoutRoomClosed: React_2.JSX.Element; MessageResend: React_2.JSX.Element; ParticipantItemSpotlighted: React_2.JSX.Element; HoldCallContextualMenuItem: React_2.JSX.Element; @@ -4195,6 +4193,7 @@ export type NotificationStackSelector = (state: CallClientState, props: CallingB // @public export interface NotificationStackStrings { assignedBreakoutRoomChanged?: NotificationStrings; + assignedBreakoutRoomClosed?: NotificationStrings; assignedBreakoutRoomOpened?: NotificationStrings; assignedBreakoutRoomOpenedPromptJoin?: NotificationStrings; breakoutRoomClosingSoon?: NotificationStrings; @@ -4270,7 +4269,7 @@ export interface NotificationStyles { } // @public (undocumented) -export type NotificationTarget = /* @conditional-compile-remove(breakout-rooms) */ 'assignedBreakoutRoomOpened' | /* @conditional-compile-remove(breakout-rooms) */ 'assignedBreakoutRoomOpenedPromptJoin' | /* @conditional-compile-remove(breakout-rooms) */ 'assignedBreakoutRoomChanged' | /* @conditional-compile-remove(breakout-rooms) */ 'breakoutRoomJoined' | /* @conditional-compile-remove(breakout-rooms) */ 'breakoutRoomClosingSoon' | 'capabilityTurnVideoOnPresent' | 'capabilityTurnVideoOnAbsent' | 'capabilityUnmuteMicPresent' | 'capabilityUnmuteMicAbsent' | /* @conditional-compile-remove(together-mode) */ 'togetherModeStarted' | /* @conditional-compile-remove(together-mode) */ 'togetherModeEnded'; +export type NotificationTarget = /* @conditional-compile-remove(breakout-rooms) */ 'assignedBreakoutRoomOpened' | /* @conditional-compile-remove(breakout-rooms) */ 'assignedBreakoutRoomOpenedPromptJoin' | /* @conditional-compile-remove(breakout-rooms) */ 'assignedBreakoutRoomChanged' | /* @conditional-compile-remove(breakout-rooms) */ 'assignedBreakoutRoomClosed' | /* @conditional-compile-remove(breakout-rooms) */ 'breakoutRoomJoined' | /* @conditional-compile-remove(breakout-rooms) */ 'breakoutRoomClosingSoon' | 'capabilityTurnVideoOnPresent' | 'capabilityTurnVideoOnAbsent' | 'capabilityUnmuteMicPresent' | 'capabilityUnmuteMicAbsent' | /* @conditional-compile-remove(together-mode) */ 'togetherModeStarted' | /* @conditional-compile-remove(together-mode) */ 'togetherModeEnded'; // @public export type NotificationType = keyof NotificationStackStrings; diff --git a/packages/communication-react/review/stable/communication-react.api.md b/packages/communication-react/review/stable/communication-react.api.md index 9e198a8eb5c..7722daee655 100644 --- a/packages/communication-react/review/stable/communication-react.api.md +++ b/packages/communication-react/review/stable/communication-react.api.md @@ -13,6 +13,9 @@ import { BackgroundBlurConfig } from '@azure/communication-calling'; import { BackgroundBlurEffect } from '@azure/communication-calling'; import { BackgroundReplacementConfig } from '@azure/communication-calling'; import { BackgroundReplacementEffect } from '@azure/communication-calling'; +import { BreakoutRoom } from '@azure/communication-calling'; +import { BreakoutRoomsSettings } from '@azure/communication-calling'; +import { BreakoutRoomsUpdatedListener } from '@azure/communication-calling'; import { Call } from '@azure/communication-calling'; import { CallAgent } from '@azure/communication-calling'; import { CallClient } from '@azure/communication-calling'; @@ -142,6 +145,17 @@ export type AdapterErrors = { [target: string]: AdapterError; }; +// @public +export interface AdapterNotification { + target: string; + timestamp: Date; +} + +// @public +export type AdapterNotifications = { + [target: string]: AdapterNotification; +}; + // @public export interface AdapterState { getState(): TState; @@ -245,6 +259,14 @@ export interface BaseCustomStyles { root?: IStyle; } +// @public +export interface BreakoutRoomsState { + assignedBreakoutRoom?: BreakoutRoom; + breakoutRoomDisplayName?: string; + breakoutRoomOriginCallId?: string; + breakoutRoomSettings?: BreakoutRoomsSettings; +} + // @public export interface CallAdapter extends CommonCallAdapter { // @deprecated @@ -298,6 +320,7 @@ export interface CallAdapterCallOperations { removeParticipant(userId: string): Promise; removeParticipant(participant: CommunicationIdentifier): Promise; resumeCall(): Promise; + returnFromBreakoutRoom(): Promise; sendDtmfTone(dtmfTone: DtmfTone_2): Promise; setCaptionLanguage(language: string): Promise; setSpokenLanguage(language: string): Promise; @@ -332,6 +355,7 @@ export type CallAdapterClientState = { isTeamsMeeting: boolean; isRoomsCall: boolean; latestErrors: AdapterErrors; + latestNotifications: AdapterNotifications; alternateCallerId?: string; environmentInfo?: EnvironmentInfo; cameraStatus?: 'On' | 'Off'; @@ -387,6 +411,7 @@ export interface CallAdapterSubscribers { off(event: 'roleChanged', listener: PropertyChangedEvent): void; off(event: 'spotlightChanged', listener: SpotlightChangedListener): void; off(event: 'mutedByOthers', listener: PropertyChangedEvent): void; + off(event: 'breakoutRoomsUpdated', listener: BreakoutRoomsUpdatedListener): void; on(event: 'participantsJoined', listener: ParticipantsJoinedListener): void; on(event: 'participantsLeft', listener: ParticipantsLeftListener): void; on(event: 'isMutedChanged', listener: IsMutedChangedListener): void; @@ -408,6 +433,7 @@ export interface CallAdapterSubscribers { on(event: 'roleChanged', listener: PropertyChangedEvent): void; on(event: 'spotlightChanged', listener: SpotlightChangedListener): void; on(event: 'mutedByOthers', listener: PropertyChangedEvent): void; + on(event: 'breakoutRoomsUpdated', listener: BreakoutRoomsUpdatedListener): void; } // @public @@ -608,7 +634,7 @@ export type CallCompositeOptions = { }; // @public -export type CallCompositePage = 'accessDeniedTeamsMeeting' | 'call' | 'configuration' | 'hold' | 'joinCallFailedDueToNoNetwork' | 'leftCall' | 'leaving' | 'lobby' | 'removedFromCall' | 'transferring' | 'badRequest'; +export type CallCompositePage = 'accessDeniedTeamsMeeting' | 'call' | 'configuration' | 'hold' | 'joinCallFailedDueToNoNetwork' | 'leftCall' | 'leaving' | 'lobby' | 'removedFromCall' | 'transferring' | 'badRequest' | 'returningFromBreakoutRoom'; // @public export interface CallCompositeProps extends BaseCompositeProps { @@ -623,6 +649,7 @@ export interface CallCompositeStrings { addSpotlightMenuLabel: string; blurBackgroundEffectButtonLabel?: string; blurBackgroundTooltip?: string; + breakoutRoomJoinedNotificationTitle: string; callRejectedMoreDetails?: string; callRejectedTitle?: string; callTimeoutBotDetails?: string; @@ -709,7 +736,11 @@ export interface CallCompositeStrings { invalidMeetingIdentifier: string; inviteToRoomRemovedDetails?: string; inviteToRoomRemovedTitle: string; + joinBreakoutRoomBannerButtonLabel: string; + joinBreakoutRoomBannerTitle: string; + joinBreakoutRoomButtonLabel: string; learnMore: string; + leaveBreakoutRoomAndMeetingButtonLabel: string; leaveConfirmButtonLabel?: string; leaveConfirmDialogContent?: string; leaveConfirmDialogTitle?: string; @@ -794,6 +825,9 @@ export interface CallCompositeStrings { resumeCallButtonLabel?: string; resumingCallButtonAriaLabel?: string; resumingCallButtonLabel?: string; + returnFromBreakoutRoomBannerButtonLabel: string; + returnFromBreakoutRoomBannerTitle: string; + returnFromBreakoutRoomButtonLabel: string; returnToCallButtonAriaDescription?: string; returnToCallButtonAriaLabel?: string; roomNotFoundDetails?: string; @@ -975,6 +1009,7 @@ export interface CallProviderProps { // @public export interface CallState { + breakoutRooms?: BreakoutRoomsState; callEndReason?: CallEndReason; callerInfo: CallerInfo; capabilitiesFeature?: CapabilitiesFeatureState; @@ -1070,6 +1105,7 @@ export interface CallWithChatAdapterManagement { // (undocumented) removeResourceFromCache(resourceDetails: ResourceDetails): void; resumeCall(): Promise; + returnFromBreakoutRoom(): Promise; sendDtmfTone: (dtmfTone: DtmfTone_2) => Promise; sendMessage(content: string, options?: SendMessageOptions): Promise; sendReadReceipt(chatMessageId: string): Promise; @@ -1142,6 +1178,8 @@ export interface CallWithChatAdapterSubscriptions { // (undocumented) off(event: 'spotlightChanged', listener: SpotlightChangedListener): void; // (undocumented) + off(event: 'breakoutRoomsUpdated', listener: BreakoutRoomsUpdatedListener): void; + // (undocumented) off(event: 'messageReceived', listener: MessageReceivedListener): void; // (undocumented) off(event: 'messageEdited', listener: MessageEditedListener): void; @@ -1194,6 +1232,8 @@ export interface CallWithChatAdapterSubscriptions { // (undocumented) on(event: 'spotlightChanged', listener: SpotlightChangedListener): void; // (undocumented) + on(event: 'breakoutRoomsUpdated', listener: BreakoutRoomsUpdatedListener): void; + // (undocumented) on(event: 'messageReceived', listener: MessageReceivedListener): void; // (undocumented) on(event: 'messageEdited', listener: MessageEditedListener): void; @@ -1233,6 +1273,7 @@ export interface CallWithChatClientState { isTeamsCall: boolean; isTeamsMeeting: boolean; latestCallErrors: AdapterErrors; + latestCallNotifications: AdapterNotifications; latestChatErrors: AdapterErrors; onResolveDeepNoiseSuppressionDependency?: () => Promise; onResolveVideoEffectDependency?: () => Promise; @@ -1377,11 +1418,13 @@ export interface CallWithChatCompositeProps extends BaseCompositeProps JSX.Element; @@ -2572,6 +2615,12 @@ export const DEFAULT_COMPONENT_ICONS: { ErrorBarCallVideoStoppedBySystem: React_2.JSX.Element; ErrorBarMutedByRemoteParticipant: React_2.JSX.Element; NotificationBarRecording: React_2.JSX.Element; + NotificationBarBreakoutRoomOpened: React_2.JSX.Element; + NotificationBarBreakoutRoomPromptJoin: React_2.JSX.Element; + NotificationBarBreakoutRoomChanged: React_2.JSX.Element; + NotificationBarBreakoutRoomJoined: React_2.JSX.Element; + NotificationBarBreakoutRoomClosingSoon: React_2.JSX.Element; + NotificationBarBreakoutRoomClosed: React_2.JSX.Element; HorizontalGalleryLeftButton: React_2.JSX.Element; HorizontalGalleryRightButton: React_2.JSX.Element; MessageDelivered: React_2.JSX.Element; @@ -2743,6 +2792,12 @@ export const DEFAULT_COMPOSITE_ICONS: { ErrorBarCallVideoStoppedBySystem: React_2.JSX.Element; ErrorBarMutedByRemoteParticipant: React_2.JSX.Element; NotificationBarRecording: React_2.JSX.Element; + NotificationBarBreakoutRoomOpened: React_2.JSX.Element; + NotificationBarBreakoutRoomPromptJoin: React_2.JSX.Element; + NotificationBarBreakoutRoomChanged: React_2.JSX.Element; + NotificationBarBreakoutRoomJoined: React_2.JSX.Element; + NotificationBarBreakoutRoomClosingSoon: React_2.JSX.Element; + NotificationBarBreakoutRoomClosed: React_2.JSX.Element; MessageResend: React_2.JSX.Element; ParticipantItemSpotlighted: React_2.JSX.Element; HoldCallContextualMenuItem: React_2.JSX.Element; @@ -3641,6 +3696,12 @@ export type NotificationStackSelector = (state: CallClientState, props: CallingB // @public export interface NotificationStackStrings { + assignedBreakoutRoomChanged?: NotificationStrings; + assignedBreakoutRoomClosed?: NotificationStrings; + assignedBreakoutRoomOpened?: NotificationStrings; + assignedBreakoutRoomOpenedPromptJoin?: NotificationStrings; + breakoutRoomClosingSoon?: NotificationStrings; + breakoutRoomJoined?: NotificationStrings; callCameraAccessDenied?: NotificationStrings; callCameraAccessDeniedSafari?: NotificationStrings; callCameraAlreadyInUse?: NotificationStrings; @@ -3708,7 +3769,7 @@ export interface NotificationStyles { } // @public (undocumented) -export type NotificationTarget = 'capabilityTurnVideoOnPresent' | 'capabilityTurnVideoOnAbsent' | 'capabilityUnmuteMicPresent' | 'capabilityUnmuteMicAbsent'; +export type NotificationTarget = 'assignedBreakoutRoomOpened' | 'assignedBreakoutRoomOpenedPromptJoin' | 'assignedBreakoutRoomChanged' | 'assignedBreakoutRoomClosed' | 'breakoutRoomJoined' | 'breakoutRoomClosingSoon' | 'capabilityTurnVideoOnPresent' | 'capabilityTurnVideoOnAbsent' | 'capabilityUnmuteMicPresent' | 'capabilityUnmuteMicAbsent'; // @public export type NotificationType = keyof NotificationStackStrings; diff --git a/packages/react-components/src/components/NotificationStack.tsx b/packages/react-components/src/components/NotificationStack.tsx index da4d82b1ff4..b4d75431cea 100644 --- a/packages/react-components/src/components/NotificationStack.tsx +++ b/packages/react-components/src/components/NotificationStack.tsx @@ -255,10 +255,15 @@ export interface NotificationStackStrings { assignedBreakoutRoomOpenedPromptJoin?: NotificationStrings; /* @conditional-compile-remove(breakout-rooms) */ /** - * Message shown in notification when the user is assigned breakout room is changed + * Message shown in notification when the user's assigned breakout room is changed */ assignedBreakoutRoomChanged?: NotificationStrings; /* @conditional-compile-remove(breakout-rooms) */ + /** + * Message shown in notification when the user's assigned breakout room is closed + */ + assignedBreakoutRoomClosed?: NotificationStrings; + /* @conditional-compile-remove(breakout-rooms) */ /** * Message shown in notification when breakout room is joined */ diff --git a/packages/react-components/src/components/RaiseHandButton.tsx b/packages/react-components/src/components/RaiseHandButton.tsx index cb48e36d3c7..208bbbc80f2 100644 --- a/packages/react-components/src/components/RaiseHandButton.tsx +++ b/packages/react-components/src/components/RaiseHandButton.tsx @@ -4,7 +4,7 @@ import React from 'react'; import { useLocale } from '../localization'; import { ControlBarButton, ControlBarButtonProps } from './ControlBarButton'; -import { DefaultPalette, IButtonStyles, mergeStyles, Theme, useTheme } from '@fluentui/react'; +import { concatStyleSets, DefaultPalette, IButtonStyles, Theme, useTheme } from '@fluentui/react'; import { _HighContrastAwareIcon } from './HighContrastAwareIcon'; /** @@ -67,7 +67,7 @@ export const RaiseHandButton = (props: RaiseHandButtonProps): JSX.Element => { return ( { setCalloutIsVisible(!calloutIsVisible)} onRenderIcon={props.onRenderIcon ?? onRenderIcon} strings={strings} diff --git a/packages/react-components/src/components/utils.ts b/packages/react-components/src/components/utils.ts index 4ac164269ce..9df187edbed 100644 --- a/packages/react-components/src/components/utils.ts +++ b/packages/react-components/src/components/utils.ts @@ -357,6 +357,8 @@ export const customNotificationIconName: Partial<{ [key in NotificationType]: st /* @conditional-compile-remove(breakout-rooms) */ assignedBreakoutRoomChanged: 'NotificationBarBreakoutRoomChanged', /* @conditional-compile-remove(breakout-rooms) */ + assignedBreakoutRoomClosed: 'NotificationBarBreakoutRoomChanged', + /* @conditional-compile-remove(breakout-rooms) */ breakoutRoomJoined: 'NotificationBarBreakoutRoomJoined', /* @conditional-compile-remove(breakout-rooms) */ breakoutRoomClosingSoon: 'NotificationBarBreakoutRoomClosingSoon', diff --git a/packages/react-components/src/localization/locales/en-US/strings.json b/packages/react-components/src/localization/locales/en-US/strings.json index b54c6d4e793..578b919a40d 100644 --- a/packages/react-components/src/localization/locales/en-US/strings.json +++ b/packages/react-components/src/localization/locales/en-US/strings.json @@ -599,6 +599,11 @@ "message": "We'll move you to your assigned room in 5 seconds.", "dismissButtonAriaLabel": "Close" }, + "assignedBreakoutRoomClosed": { + "title": "Your breakout room has closed", + "message": "We'll move you back to your meeting in 5 seconds.", + "dismissButtonAriaLabel": "Close" + }, "assignedBreakoutRoomOpenedPromptJoin": { "title": "Join breakout room?", "message": "You've been assigned to a breakout room.", diff --git a/packages/react-components/src/theming/icons.tsx b/packages/react-components/src/theming/icons.tsx index 220e7f790a7..34029d0f5ca 100644 --- a/packages/react-components/src/theming/icons.tsx +++ b/packages/react-components/src/theming/icons.tsx @@ -315,6 +315,8 @@ export const DEFAULT_COMPONENT_ICONS = { NotificationBarBreakoutRoomJoined: , /* @conditional-compile-remove(breakout-rooms) */ NotificationBarBreakoutRoomClosingSoon: , + /* @conditional-compile-remove(breakout-rooms) */ + NotificationBarBreakoutRoomClosed: , HorizontalGalleryLeftButton: , HorizontalGalleryRightButton: , MessageDelivered: , diff --git a/packages/react-composites/src/composites/CallComposite/CallComposite.tsx b/packages/react-composites/src/composites/CallComposite/CallComposite.tsx index 80e4f219e7c..e5ea6cd5c4f 100644 --- a/packages/react-composites/src/composites/CallComposite/CallComposite.tsx +++ b/packages/react-composites/src/composites/CallComposite/CallComposite.tsx @@ -701,6 +701,7 @@ const MainScreen = (props: MainScreenProps): JSX.Element => { ); break; case 'call': + case 'returningFromBreakoutRoom': pageElement = ( void): void { this.emitter.on('callEnded', handler); } @@ -280,17 +287,19 @@ class CallContext { const transferCall = latestAcceptedTransfer ? clientState.calls[latestAcceptedTransfer.callId] : undefined; /* @conditional-compile-remove(breakout-rooms) */ - const originCall = call?.breakoutRooms?.breakoutRoomOriginCallId - ? clientState.calls[call?.breakoutRooms?.breakoutRoomOriginCallId] - : latestEndedCall?.breakoutRooms?.breakoutRoomOriginCallId - ? clientState.calls[latestEndedCall?.breakoutRooms?.breakoutRoomOriginCallId] - : undefined; + if (call?.state === 'Connected' || call?.state === 'Connecting') { + this.setIsReturningFromBreakoutRoom(false); + } + + let isReturningFromBreakoutRoom = false; + /* @conditional-compile-remove(breakout-rooms) */ + isReturningFromBreakoutRoom = this.isReturningFromBreakoutRoom; const newPage = getCallCompositePage( call, latestEndedCall, transferCall, - /* @conditional-compile-remove(breakout-rooms) */ originCall, + isReturningFromBreakoutRoom, /* @conditional-compile-remove(unsupported-browser) */ environmentInfo ); if (!IsCallEndedPage(oldPage) && IsCallEndedPage(newPage)) { @@ -1220,21 +1229,20 @@ export class AzureCommunicationCallAdapter { - if (!this.originCall) { - throw new Error('Could not return from breakout room because the origin call could not be retrieved.'); - } + const callState = this.call?.id ? this.callClient.getState().calls[this.call.id] : undefined; + const assignedBreakoutRoom = callState?.breakoutRooms?.assignedBreakoutRoom; - if (this.call?.id === this.originCall.id) { - console.error('Return from breakout room will not be done because current call is the origin call.'); - return; + if (!assignedBreakoutRoom) { + throw new Error( + 'Could not return from breakout room because assigned breakout room state could not be retrieved.' + ); } - const breakoutRoomCall = this.call; - this.processNewCall(this.originCall); - await this.resumeCall(); - if (breakoutRoomCall?.state && !['Disconnecting', 'Disconnected'].includes(breakoutRoomCall.state)) { - breakoutRoomCall.hangUp(); - } + this.context.setIsReturningFromBreakoutRoom(true); + + const mainMeeting = await assignedBreakoutRoom.returnToMainMeeting(); + this.originCall = mainMeeting; + this.processNewCall(mainMeeting); } public getState(): CallAdapterState { @@ -1529,15 +1537,9 @@ export class AzureCommunicationCallAdapter { - return ( - callState.breakoutRooms?.breakoutRoomOriginCallId === originCallId && callState.id !== currentBreakoutRoomCallId - ); + return callState.breakoutRooms?.breakoutRoomSettings && callState.id !== currentBreakoutRoomCallId; }); // Hang up other breakout room calls diff --git a/packages/react-composites/src/composites/CallComposite/adapter/CallAdapter.ts b/packages/react-composites/src/composites/CallComposite/adapter/CallAdapter.ts index 9825a9b6d72..0d18204c839 100644 --- a/packages/react-composites/src/composites/CallComposite/adapter/CallAdapter.ts +++ b/packages/react-composites/src/composites/CallComposite/adapter/CallAdapter.ts @@ -60,7 +60,8 @@ export type CallCompositePage = | 'removedFromCall' | /* @conditional-compile-remove(unsupported-browser) */ 'unsupportedEnvironment' | 'transferring' - | 'badRequest'; + | 'badRequest' + | /* @conditional-compile-remove(breakout-rooms) */ 'returningFromBreakoutRoom'; /** * Subset of CallCompositePages that represent an end call state. diff --git a/packages/react-composites/src/composites/CallComposite/components/BreakoutRoomsBanner.tsx b/packages/react-composites/src/composites/CallComposite/components/BreakoutRoomsBanner.tsx index 6f02eaec7bc..f828ed26a04 100644 --- a/packages/react-composites/src/composites/CallComposite/components/BreakoutRoomsBanner.tsx +++ b/packages/react-composites/src/composites/CallComposite/components/BreakoutRoomsBanner.tsx @@ -14,7 +14,7 @@ import { CompositeLocale } from '../../localization'; /* @conditional-compile-remove(breakout-rooms) */ import { useSelector } from '../hooks/useSelector'; /* @conditional-compile-remove(breakout-rooms) */ -import { getAssignedBreakoutRoom, getBreakoutRoomSettings } from '../selectors/baseSelectors'; +import { getAssignedBreakoutRoom, getBreakoutRoomSettings, getLatestNotifications } from '../selectors/baseSelectors'; /* @conditional-compile-remove(breakout-rooms) */ import { Banner } from './Banner'; @@ -30,8 +30,16 @@ export const BreakoutRoomsBanner = (props: { const assignedBreakoutRoom = useSelector(getAssignedBreakoutRoom); const breakoutRoomSettings = useSelector(getBreakoutRoomSettings); + const latestNotifications = useSelector(getLatestNotifications); - if (assignedBreakoutRoom && assignedBreakoutRoom.state === 'open' && assignedBreakoutRoom.call) { + if ( + assignedBreakoutRoom && + assignedBreakoutRoom.state === 'open' && + // Breakout room settings are only defined in a breakout room so we use this to ensure + // the button is not shown when already in a breakout room + !breakoutRoomSettings && + !latestNotifications['assignedBreakoutRoomOpened'] + ) { return ( { const [isPromptOpen, setIsPromptOpen] = useState(false); const [promptProps, setPromptProps] = useState(); + /* @conditional-compile-remove(breakout-rooms) */ + const page = useSelector((state) => state.page); + /* @conditional-compile-remove(breakout-rooms) */ + const userId = useSelector((state) => state.userId); + /* @conditional-compile-remove(breakout-rooms) */ + const displayName = useSelector((state) => state.displayName); + + /* @conditional-compile-remove(breakout-rooms) */ + const onRenderAvatar = useCallback( + (userId?: string, options?: CustomAvatarOptions) => { + return ( + + + {options?.coinSize && ( + + )} + + + ); + }, + [props.onFetchAvatarPersonaData] + ); + + let galleryContentWhenNotInCall = <>; + /* @conditional-compile-remove(breakout-rooms) */ + if (!_isInCall(callStatus) && page === 'returningFromBreakoutRoom') { + galleryContentWhenNotInCall = ( + + ); + } + const onRenderGalleryContentTrampoline = (): JSX.Element => { if (dtmfDialerPresent) { return ( @@ -186,7 +230,7 @@ export const CallPage = (props: CallPageProps): JSX.Element => { ) ) : ( - <> + galleryContentWhenNotInCall ) } updateSidePaneRenderer={props.updateSidePaneRenderer} diff --git a/packages/react-composites/src/composites/CallComposite/utils/Utils.ts b/packages/react-composites/src/composites/CallComposite/utils/Utils.ts index 94788073f95..20c132b7be1 100644 --- a/packages/react-composites/src/composites/CallComposite/utils/Utils.ts +++ b/packages/react-composites/src/composites/CallComposite/utils/Utils.ts @@ -264,7 +264,7 @@ type GetCallCompositePageFunction = (( call: CallState | undefined, previousCall: CallState | undefined, transferCall?: CallState, - originCall?: CallState, + isReturningFromBreakoutRoom?: boolean, /* @conditional-compile-remove(unsupported-browser) */ unsupportedBrowserInfo?: { environmentInfo?: EnvironmentInfo; unsupportedBrowserVersionOptedIn?: boolean; @@ -287,7 +287,7 @@ export const getCallCompositePage: GetCallCompositePageFunction = ( call, previousCall?, transferCall?: CallState, - originCall?: CallState, + isReturningFromBreakoutRoom?: boolean, unsupportedBrowserInfo?: { /* @conditional-compile-remove(unsupported-browser) */ environmentInfo?: EnvironmentInfo; @@ -308,6 +308,11 @@ export const getCallCompositePage: GetCallCompositePageFunction = ( return 'transferring'; } + /* @conditional-compile-remove(breakout-rooms) */ + 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 @@ -333,11 +338,6 @@ export const getCallCompositePage: GetCallCompositePageFunction = ( } } - // /* @conditional-compile-remove(breakout-rooms) */ - if (previousCall?.breakoutRooms?.breakoutRoomOriginCallId && originCall) { - return 'call'; - } - if (previousCall) { const reason = getCallEndReason(previousCall); switch (reason) { diff --git a/packages/react-composites/src/composites/CallWithChatComposite/adapter/AzureCommunicationCallWithChatAdapter.ts b/packages/react-composites/src/composites/CallWithChatComposite/adapter/AzureCommunicationCallWithChatAdapter.ts index 86048758c13..ae46158dddb 100644 --- a/packages/react-composites/src/composites/CallWithChatComposite/adapter/AzureCommunicationCallWithChatAdapter.ts +++ b/packages/react-composites/src/composites/CallWithChatComposite/adapter/AzureCommunicationCallWithChatAdapter.ts @@ -204,7 +204,12 @@ export class AzureCommunicationCallWithChatAdapter implements CallWithChatAdapte await this.breakoutRoomJoined(eventData.data); } else if (eventData.type === 'assignedBreakoutRooms') { if (!eventData.data || eventData.data.state === 'closed') { - await this.returnFromBreakoutRoom(); + if ( + this.originCallChatAdapter && + this.originCallChatAdapter?.getState().thread.threadId !== this.chatAdapter?.getState().thread.threadId + ) { + this.updateChatAdapter(this.originCallChatAdapter); + } } } }); diff --git a/packages/react-composites/src/composites/common/ControlBar/CommonCallControlBar.tsx b/packages/react-composites/src/composites/common/ControlBar/CommonCallControlBar.tsx index b652a417331..aabca7549e3 100644 --- a/packages/react-composites/src/composites/common/ControlBar/CommonCallControlBar.tsx +++ b/packages/react-composites/src/composites/common/ControlBar/CommonCallControlBar.tsx @@ -417,7 +417,9 @@ export const CommonCallControlBar = forwardRef => {