diff --git a/docs/Components/CallComposite.md b/docs/Components/CallComposite.md index 25fcfc8..391d7b2 100644 --- a/docs/Components/CallComposite.md +++ b/docs/Components/CallComposite.md @@ -105,12 +105,19 @@ To raise the hand during a call, call the `RaiseHandAsync()` method. To lower the hand during a call, call the `LowerHandAsync()` method. +### Gets the state of the CallAdapter +To retrieve the current state of the `CallAdapter`, call the `GetStateAsync()` method. + +You can also subscribe to the `OnStateChanged` event which is raised when the state of the `CallAdapter` is changed. + ### Events You can subsribe to the following asynchronous events using a standard delegate method: - `OnCallEnded`: Occurs then the call is ended. - `OnMicrophoneMuteChanged`: Occurs when the microphone of a participant is mute/unmute. - `OnParticipantJoined`: Occurs when a participant join the call. - `OnParticipantLeft`: Occurs when a participant leave the call. +- `OnStateChanged`: Occurs when the `CallAdapterState` has been changed. + You can also call manually the `GetStateAsync()` method to retrieve the last state of the `CallAdapter` ### Dispose the resources It is recommanded to implement the `IAsyncDisposable` method in the class which create diff --git a/docs/PortedApi.md b/docs/PortedApi.md index 0f0015e..0446ae5 100644 --- a/docs/PortedApi.md +++ b/docs/PortedApi.md @@ -10,9 +10,9 @@ which has been ported to this library. | Method | Available | Remarks | |-------------------------------|------------|------------------------------------------------------| -| onStateChange | TODO | | -| offStateChange | TODO | | -| getState | TODO | | +| onStateChange | **Done** | | +| offStateChange | **Done** | | +| getState | Partially | Need to fully wrap the CallAdapterState object | | dispose | **Done** | | | holdCall (Beta) | No | Currently in beta in Microsoft library | | joinCall (Deprecated) | No | Deprecated | @@ -68,3 +68,29 @@ which has been ported to this library. | transferAccepted | TODO | | | capabilitiesChanged | TODO | | | spotlightChanged | TODO | | + + +### CallAdapterState +| Name | Available | Remarks | +|--------------------------------------|-----------|---------| +| userId | **Done** | | +| displayName | **Done** | | +| call | TODO | | +| targetCallees | TODO | | +| devices | TODO | | +| endedCall | TODO | | +| isTeamsCall | **Done** | | +| isRoomsCall | **Done** | | +| latestErrors | TODO | | +| alternateCallerId | TODO | | +| environmentInfo | TODO | | +| cameraStatus | **Done** | | +| videoBackgroundImages | TODO | | +| onResolveVideoEffectDependency | TODO | | +| selectedVideoBackgroundEffect | TODO | | +| acceptedTransferCallState | TODO | | +| hideAttendeeNames | TODO | | +| sounds | TODO | | +| isLocalPreviewMicrophoneEnabled | **Done** | | +| page | **Done** | | +| unsupportedBrowserVersionsAllowed | TODO | | \ No newline at end of file diff --git a/src/Communication.UI.Blazor/Calling/CallAdapter.cs b/src/Communication.UI.Blazor/Calling/CallAdapter.cs index 1e6fe04..42aeca8 100644 --- a/src/Communication.UI.Blazor/Calling/CallAdapter.cs +++ b/src/Communication.UI.Blazor/Calling/CallAdapter.cs @@ -38,10 +38,42 @@ internal CallAdapter(IJSObjectReference module) /// public event AsyncEventHandler? OnParticipantLeft; + /// + public event AsyncEventHandler? OnStateChanged; + internal Guid Id { get; } internal IJSObjectReference Module { get; } + /// + public void Dispose() + { + if (this.callbackEvent != null) + { + this.callbackEvent.Dispose(); + this.callbackEvent = null; + } + } + + /// + public async ValueTask DisposeAsync() + { + if (this.callbackEvent != null) + { + await this.Module.InvokeVoidAsync("dispose", this.Id); + } + + this.Dispose(); + } + + /// + public async Task GetStateAsync() + { + ObjectDisposedException.ThrowIf(this.callbackEvent is null, this); + + return await this.Module.InvokeAsync("adapterGetState", this.Id); + } + /// public async Task JoinCallAsync(JoinCallOptions options) { @@ -130,27 +162,6 @@ public async Task UnmuteAsync() await this.Module.InvokeVoidAsync("adapterUnmute", this.Id); } - /// - public async ValueTask DisposeAsync() - { - if (this.callbackEvent != null) - { - await this.Module.InvokeVoidAsync("dispose", this.Id); - } - - this.Dispose(); - } - - /// - public void Dispose() - { - if (this.callbackEvent != null) - { - this.callbackEvent.Dispose(); - this.callbackEvent = null; - } - } - internal async Task InitializeAsync(CallAdapterArgs args) { await this.Module.InvokeVoidAsync("createCallAdapter", this.Id, args, this.callbackEvent!.Reference); @@ -218,6 +229,15 @@ public async Task OnParticipantsLeftAsync(RemoteParticipant[] removed) } } } + + [JSInvokable] + public async Task OnStateChangedAsync(CallAdapterState state) + { + if (this.owner.OnStateChanged is not null) + { + await this.owner.OnStateChanged(new StateChangedEvent(state)); + } + } } } } diff --git a/src/Communication.UI.Blazor/Calling/CallAdapterState.cs b/src/Communication.UI.Blazor/Calling/CallAdapterState.cs new file mode 100644 index 0000000..7da7637 --- /dev/null +++ b/src/Communication.UI.Blazor/Calling/CallAdapterState.cs @@ -0,0 +1,83 @@ +//----------------------------------------------------------------------- +// +// Copyright (c) P.O.S Informatique. All rights reserved. +// +//----------------------------------------------------------------------- + +namespace PosInformatique.Azure.Communication.UI.Blazor +{ + using System.Text.Json.Serialization; + + /// + /// Represents the current state. + /// + public class CallAdapterState + { + /// + /// Initializes a new instance of the class. + /// + /// The of the current user. + public CallAdapterState(CommunicationUserKind userId) + { + this.UserId = userId; + } + + /// + /// Gets the of the current user. + /// + [JsonPropertyName("userId")] + [JsonPropertyOrder(1)] + public CommunicationUserKind UserId { get; } + + /// + /// Gets the display name of the current user. + /// + [JsonPropertyName("displayName")] + [JsonPropertyOrder(2)] + [JsonInclude] + public string? DisplayName { get; init; } + + /// + /// Gets a value indicating whether if the current call is Teams call. + /// + [JsonPropertyName("isTeamsCall")] + [JsonPropertyOrder(7)] + [JsonInclude] + public bool IsTeamsCall { get; init; } + + /// + /// Gets a value indicating whether if the call is a rooms call. + /// + [JsonPropertyName("isRoomsCall")] + [JsonPropertyOrder(8)] + [JsonInclude] + public bool IsRoomsCall { get; init; } + + /// + /// Gets a value indicating whether the local participant's camera is on. + /// To be used when creating a custom control bar with the CallComposite. + /// + [JsonPropertyName("cameraStatus")] + [JsonPropertyOrder(12)] + [JsonConverter(typeof(JsonStringEnumConverter))] + [JsonInclude] + public CameraStatus? CameraStatus { get; init; } + + /// + /// Gets a value indicating whether if the microphone is enabled. + /// + [JsonPropertyName("isLocalPreviewMicrophoneEnabled")] + [JsonPropertyOrder(50)] + [JsonInclude] + public bool IsLocalPreviewMicrophoneEnabled { get; init; } + + /// + /// Gets the current page display on the . + /// + [JsonPropertyName("page")] + [JsonPropertyOrder(51)] + [JsonConverter(typeof(JsonCamelCaseStringEnumConverter))] + [JsonInclude] + public CallCompositePage Page { get; init; } + } +} diff --git a/src/Communication.UI.Blazor/Calling/CallComposite.razor.js b/src/Communication.UI.Blazor/Calling/CallComposite.razor.js index 978e292..da2c060 100644 --- a/src/Communication.UI.Blazor/Calling/CallComposite.razor.js +++ b/src/Communication.UI.Blazor/Calling/CallComposite.razor.js @@ -36,6 +36,11 @@ export async function createCallAdapter(id, args, eventCallback) { return eventCallback.invokeMethodAsync('OnParticipantsLeftAsync', event.removed.map(createRemoteParticipant)); }); + adapter.onStateChange((state) => { + console.log(state); + return eventCallback.invokeMethodAsync('OnStateChangedAsync', createState(state)); + }); + registerAdapter(id, adapter); } @@ -48,6 +53,13 @@ export function initializeControl(divElement, adapterId, callControls) { createRoot(divElement).render(element); } +export function adapterGetState(id) { + + const adapter = getAdapter(id); + + return createState(adapter.getState()); +} + export function adapterJoinCall(id, options) { const adapter = getAdapter(id); @@ -169,6 +181,18 @@ function createRemoteParticipant(remoteParticipant) { }; } +function createState(state) { + return { + cameraStatus: state.cameraStatus, + displayName: state.displayName, + isLocalPreviewMicrophoneEnabled: state.isLocalPreviewMicrophoneEnabled, + isRoomsCall: state.isRoomsCall, + isTeamsCall: state.isTeamsCall, + page: state.page, + userId: state.userId, + }; +} + function createVideoDevice(videoDevice) { return { name: videoDevice.name, diff --git a/src/Communication.UI.Blazor/Calling/CallCompositePage.cs b/src/Communication.UI.Blazor/Calling/CallCompositePage.cs new file mode 100644 index 0000000..157e113 --- /dev/null +++ b/src/Communication.UI.Blazor/Calling/CallCompositePage.cs @@ -0,0 +1,69 @@ +//----------------------------------------------------------------------- +// +// Copyright (c) P.O.S Informatique. All rights reserved. +// +//----------------------------------------------------------------------- + +namespace PosInformatique.Azure.Communication.UI.Blazor +{ + /// + /// Major UI screens shown in the . + /// + public enum CallCompositePage + { + /// + /// The teams meeting is denied. + /// + AccessDeniedTeamsMeeting, + + /// + /// Call is ongoing. + /// + Call, + + /// + /// Configuration step page. + /// + Configuration, + + /// + /// The call is currently hold. + /// + Hold, + + /// + /// The join call has been failed to network issues. + /// + JoinCallFailedDueToNoNetwork, + + /// + /// The user has left the call. + /// + LeftCall, + + /// + /// The user is currently leaving the call. + /// + Leaving, + + /// + /// The user is waiting in the lobby. + /// + Lobby, + + /// + /// The user has been removed from the call. + /// + RemovedFromCall, + + /// + /// The can not be loaded because the current environment is not supported. + /// + UnsupportedEnvironment, + + /// + /// Transferring the current call. + /// + Transferring, + } +} diff --git a/src/Communication.UI.Blazor/Calling/CameraStatus.cs b/src/Communication.UI.Blazor/Calling/CameraStatus.cs new file mode 100644 index 0000000..0d6acf8 --- /dev/null +++ b/src/Communication.UI.Blazor/Calling/CameraStatus.cs @@ -0,0 +1,24 @@ +//----------------------------------------------------------------------- +// +// Copyright (c) P.O.S Informatique. All rights reserved. +// +//----------------------------------------------------------------------- + +namespace PosInformatique.Azure.Communication.UI.Blazor +{ + /// + /// Represents the camera status. + /// + public enum CameraStatus + { + /// + /// The camera is off. + /// + Off, + + /// + /// The camera is on. + /// + On, + } +} diff --git a/src/Communication.UI.Blazor/Calling/CommunicationUserIdentifier.cs b/src/Communication.UI.Blazor/Calling/CommunicationUserIdentifier.cs index 57cea4c..2e17db9 100644 --- a/src/Communication.UI.Blazor/Calling/CommunicationUserIdentifier.cs +++ b/src/Communication.UI.Blazor/Calling/CommunicationUserIdentifier.cs @@ -9,7 +9,7 @@ namespace PosInformatique.Azure.Communication.UI.Blazor using System.Text.Json.Serialization; /// - /// Represents an Azure Communication user. + /// Represents an Azure Communication Services user. /// [JsonDerivedType(typeof(CommunicationUserKind), "communicationUser")] [JsonPolymorphic(TypeDiscriminatorPropertyName = "kind")] @@ -18,17 +18,26 @@ public abstract class CommunicationUserIdentifier /// /// Initializes a new instance of the class. /// - /// Id of the CommunicationUser as returned from the Communication Service. + /// Id of the CommunicationUser as returned from the Communication Services. protected CommunicationUserIdentifier(string communicationUserId) { this.CommunicationUserId = communicationUserId; } /// - /// Gets the id of the CommunicationUser as returned from the Communication Service. + /// Gets the id of the CommunicationUser as returned from the Communication Servicse. /// [JsonPropertyName("communicationUserId")] [JsonPropertyOrder(1)] public string CommunicationUserId { get; } + + /// + /// Returns the id of the CommunicationUser as returned from the Communication Services. + /// + /// the id of the CommunicationUser as returned from the Communication Services. + public override string ToString() + { + return this.CommunicationUserId; + } } } diff --git a/src/Communication.UI.Blazor/Calling/Events/StateChangedEvent.cs b/src/Communication.UI.Blazor/Calling/Events/StateChangedEvent.cs new file mode 100644 index 0000000..f62c8c7 --- /dev/null +++ b/src/Communication.UI.Blazor/Calling/Events/StateChangedEvent.cs @@ -0,0 +1,28 @@ +//----------------------------------------------------------------------- +// +// Copyright (c) P.O.S Informatique. All rights reserved. +// +//----------------------------------------------------------------------- + +namespace PosInformatique.Azure.Communication.UI.Blazor +{ + /// + /// Contains information when the state of the has been changed. + /// + public class StateChangedEvent + { + /// + /// Initializes a new instance of the class. + /// + /// New state of the . + public StateChangedEvent(CallAdapterState state) + { + this.State = state; + } + + /// + /// Gets the new state of the . + /// + public CallAdapterState State { get; } + } +} diff --git a/src/Communication.UI.Blazor/Calling/ICallAdapter.cs b/src/Communication.UI.Blazor/Calling/ICallAdapter.cs index eaa9ed2..9c221fb 100644 --- a/src/Communication.UI.Blazor/Calling/ICallAdapter.cs +++ b/src/Communication.UI.Blazor/Calling/ICallAdapter.cs @@ -33,6 +33,18 @@ public interface ICallAdapter : IAsyncDisposable /// event AsyncEventHandler? OnParticipantLeft; + /// + /// Occurs when the state of the has been changed. + /// + event AsyncEventHandler? OnStateChanged; + + /// + /// Get the current state of the . + /// + /// A that represents the asynchronous invocation which contains the . + /// If the has already been disposed. + Task GetStateAsync(); + /// /// Join an existing call. /// diff --git a/src/Communication.UI.Blazor/Communication.UI.Blazor.csproj b/src/Communication.UI.Blazor/Communication.UI.Blazor.csproj index e5062de..b4343ed 100644 --- a/src/Communication.UI.Blazor/Communication.UI.Blazor.csproj +++ b/src/Communication.UI.Blazor/Communication.UI.Blazor.csproj @@ -13,11 +13,13 @@ 1.2.0 - Add the following APIs in the CallAdapter: + - GetStateAsync() + - LowerHandAsync() + - OnStateChanged event. + - RaiseHandAsync() - QueryCamerasAsync() - QueryMicrophonesAsync() - QuerySpeakersAsync() - - LowerHandAsync() - - RaiseHandAsync() 1.1.0 - Refactoring to separate the CallAdapter and the CallComposite to reflect the architecture of the Communication UI Library. diff --git a/src/Communication.UI.Blazor/JsonCamelCaseStringEnumConverter.cs b/src/Communication.UI.Blazor/JsonCamelCaseStringEnumConverter.cs new file mode 100644 index 0000000..e2b6f7b --- /dev/null +++ b/src/Communication.UI.Blazor/JsonCamelCaseStringEnumConverter.cs @@ -0,0 +1,19 @@ +//----------------------------------------------------------------------- +// +// Copyright (c) P.O.S Informatique. All rights reserved. +// +//----------------------------------------------------------------------- + +namespace PosInformatique.Azure.Communication.UI.Blazor +{ + using System.Text.Json; + using System.Text.Json.Serialization; + + internal class JsonCamelCaseStringEnumConverter : JsonStringEnumConverter + { + public JsonCamelCaseStringEnumConverter() + : base(JsonNamingPolicy.CamelCase) + { + } + } +} diff --git a/tests/Communication.UI.Blazor.Demo/Pages/Home.razor b/tests/Communication.UI.Blazor.Demo/Pages/Home.razor index 36920e3..b1962cb 100644 --- a/tests/Communication.UI.Blazor.Demo/Pages/Home.razor +++ b/tests/Communication.UI.Blazor.Demo/Pages/Home.razor @@ -63,6 +63,8 @@ + +
@@ -94,6 +96,22 @@
+@if (this.currentState is not null) +{ +
+ State: +
    +
  • CameraStatus: @this.currentState.CameraStatus
  • +
  • DisplayName: @this.currentState.DisplayName
  • +
  • IsLocalPreviewMicrophoneEnabled: @this.currentState.IsLocalPreviewMicrophoneEnabled
  • +
  • IsRoomsCall: @this.currentState.IsRoomsCall
  • +
  • IsTeamsCall: @this.currentState.IsTeamsCall
  • +
  • Page: @this.currentState.Page
  • +
  • UserId: @this.currentState.UserId
  • +
+
+} + +// Copyright (c) P.O.S Informatique. All rights reserved. +// +//----------------------------------------------------------------------- + +namespace PosInformatique.Azure.Communication.UI.Blazor.Tests +{ + public class CallAdapterStateTest + { + [Fact] + public void Constructor() + { + var userId = new CommunicationUserKind(default); + + var state = new CallAdapterState(userId); + + state.CameraStatus.Should().BeNull(); + state.DisplayName.Should().BeNull(); + state.IsLocalPreviewMicrophoneEnabled.Should().BeFalse(); + state.IsRoomsCall.Should().BeFalse(); + state.IsTeamsCall.Should().BeFalse(); + state.Page.Should().Be(CallCompositePage.AccessDeniedTeamsMeeting); + state.UserId.Should().BeSameAs(userId); + } + + [Fact] + public void Serialization() + { + var state = new CallAdapterState(new CommunicationUserKind("The user id")) + { + CameraStatus = CameraStatus.On, + DisplayName = "The display name", + Page = CallCompositePage.JoinCallFailedDueToNoNetwork, + IsLocalPreviewMicrophoneEnabled = true, + IsRoomsCall = true, + IsTeamsCall = true, + }; + + state.Should().BeJsonSerializableInto(new + { + userId = new + { + communicationUserId = "The user id", + }, + displayName = "The display name", + isTeamsCall = true, + isRoomsCall = true, + cameraStatus = "On", + isLocalPreviewMicrophoneEnabled = true, + page = "joinCallFailedDueToNoNetwork", + }); + } + + [Fact] + public void Deserialization() + { + var json = new + { + userId = new + { + communicationUserId = "The user id", + }, + displayName = "The display name", + isTeamsCall = true, + isRoomsCall = true, + cameraStatus = "On", + isLocalPreviewMicrophoneEnabled = true, + page = "joinCallFailedDueToNoNetwork", + }; + + json.Should().BeJsonDeserializableInto(new CallAdapterState(new CommunicationUserKind("The user id")) + { + CameraStatus = CameraStatus.On, + DisplayName = "The display name", + Page = CallCompositePage.JoinCallFailedDueToNoNetwork, + IsLocalPreviewMicrophoneEnabled = true, + IsRoomsCall = true, + IsTeamsCall = true, + }); + } + } +} \ No newline at end of file diff --git a/tests/Communication.UI.Blazor.Tests/Calling/CallAdapterTest.cs b/tests/Communication.UI.Blazor.Tests/Calling/CallAdapterTest.cs index 4a66caa..a94d6fd 100644 --- a/tests/Communication.UI.Blazor.Tests/Calling/CallAdapterTest.cs +++ b/tests/Communication.UI.Blazor.Tests/Calling/CallAdapterTest.cs @@ -37,6 +37,10 @@ public async Task DisposeAsync() await callAdapter.DisposeAsync(); + await callAdapter.Invoking(c => c.GetStateAsync()) + .Should().ThrowExactlyAsync() + .WithMessage("Cannot access a disposed object.\r\nObject name: 'PosInformatique.Azure.Communication.UI.Blazor.CallAdapter'."); + await callAdapter.Invoking(c => c.JoinCallAsync(default)) .Should().ThrowExactlyAsync() .WithMessage("Cannot access a disposed object.\r\nObject name: 'PosInformatique.Azure.Communication.UI.Blazor.CallAdapter'."); @@ -84,6 +88,45 @@ await callAdapter.Invoking(c => c.UnmuteAsync()) module.VerifyAll(); } + [Fact] + public async Task GetStateAsync() + { + Guid adapterId = default; + + var state = new CallAdapterState(default); + + var module = new Mock(MockBehavior.Strict); + module.Setup(m => m.InvokeAsync("adapterGetState", It.IsAny())) + .Callback((string _, object[] a) => + { + a.Should().HaveCount(1); + + adapterId = a[0].As(); + }) + .ReturnsAsync(state); + + var adapter = new CallAdapter(module.Object); + + var result = await adapter.GetStateAsync(); + + adapterId.Should().Be(adapter.Id); + result.Should().BeSameAs(state); + + module.VerifyAll(); + } + + [Fact] + public async Task GetStateAsync_AlreadyDisposed() + { + var adapter = new CallAdapter(default); + + adapter.Dispose(); + + await adapter.Invoking(c => c.GetStateAsync()) + .Should().ThrowExactlyAsync() + .WithMessage("Cannot access a disposed object.\r\nObject name: 'PosInformatique.Azure.Communication.UI.Blazor.CallAdapter'."); + } + [Fact] public async Task InitializeAsync() { @@ -184,6 +227,22 @@ public async Task InitializeAsync() count.Should().Be(2); + // Check the OnStateChangedAsync event + var state = new CallAdapterState(default); + var onStateChangedCalled = false; + + adapter.OnStateChanged += new AsyncEventHandler(e => + { + e.State.Should().BeSameAs(state); + onStateChangedCalled = true; + + return Task.CompletedTask; + }); + + callBackReference.Invoke("OnStateChangedAsync", state); + + onStateChangedCalled.Should().BeTrue(); + module.VerifyAll(); } diff --git a/tests/Communication.UI.Blazor.Tests/Calling/CommunicationUserKindTest.cs b/tests/Communication.UI.Blazor.Tests/Calling/CommunicationUserKindTest.cs index aa3e562..0f4931a 100644 --- a/tests/Communication.UI.Blazor.Tests/Calling/CommunicationUserKindTest.cs +++ b/tests/Communication.UI.Blazor.Tests/Calling/CommunicationUserKindTest.cs @@ -16,6 +16,14 @@ public void Constructor() user.CommunicationUserId.Should().Be("The id"); } + [Fact] + public void ToString_Test() + { + var user = new CommunicationUserKind("The id"); + + user.CommunicationUserId.ToString().Should().Be("The id"); + } + [Fact] public void Serialization() { diff --git a/tests/Communication.UI.Blazor.Tests/Calling/StateChangedEventTest.cs b/tests/Communication.UI.Blazor.Tests/Calling/StateChangedEventTest.cs new file mode 100644 index 0000000..4c21fd3 --- /dev/null +++ b/tests/Communication.UI.Blazor.Tests/Calling/StateChangedEventTest.cs @@ -0,0 +1,21 @@ +//----------------------------------------------------------------------- +// +// Copyright (c) P.O.S Informatique. All rights reserved. +// +//----------------------------------------------------------------------- + +namespace PosInformatique.Azure.Communication.UI.Blazor.Tests +{ + public class StateChangedEventTest + { + [Fact] + public void Constructor() + { + var state = new CallAdapterState(default); + + var @event = new StateChangedEvent(state); + + @event.State.Should().BeSameAs(state); + } + } +} \ No newline at end of file