diff --git a/.github/workflows/github-actions-ci.yaml b/.github/workflows/github-actions-ci.yaml index 9768dc7..5aa2dc3 100644 --- a/.github/workflows/github-actions-ci.yaml +++ b/.github/workflows/github-actions-ci.yaml @@ -10,10 +10,10 @@ jobs: build: runs-on: ubuntu-latest steps: - - uses: actions/checkout@v3 + - uses: actions/checkout@v4 - name: Setup .NET 8.x - uses: actions/setup-dotnet@v3 + uses: actions/setup-dotnet@v4 with: dotnet-version: '8.x' diff --git a/.github/workflows/github-actions-release.yml b/.github/workflows/github-actions-release.yml index c9184c3..f71ab9d 100644 --- a/.github/workflows/github-actions-release.yml +++ b/.github/workflows/github-actions-release.yml @@ -7,7 +7,7 @@ on: type: string description: The version of the library required: true - default: 1.0.0 + default: 1.1.0 VersionSuffix: type: string description: The version suffix of the library (for example rc.1) @@ -18,10 +18,10 @@ jobs: build: runs-on: ubuntu-latest steps: - - uses: actions/checkout@v3 + - uses: actions/checkout@v4 - name: Setup .NET 8.x - uses: actions/setup-dotnet@v3 + uses: actions/setup-dotnet@v4 with: dotnet-version: '8.x' diff --git a/Communication.UI.Blazor.sln b/Communication.UI.Blazor.sln index 577ef8c..3afa53e 100644 --- a/Communication.UI.Blazor.sln +++ b/Communication.UI.Blazor.sln @@ -33,6 +33,9 @@ EndProject Project("{9A19103F-16F7-4668-BE54-9A1E7A4F7556}") = "Communication.UI.Blazor.Demo", "tests\Communication.UI.Blazor.Demo\Communication.UI.Blazor.Demo.csproj", "{0499E905-30D5-451E-BFAE-E900E8227075}" EndProject Project("{2150E333-8FDC-42A3-9474-1A3956D46DE8}") = "docs", "docs", "{8A9FB002-346C-4751-A00E-158921926CB6}" + ProjectSection(SolutionItems) = preProject + docs\PortedApi.md = docs\PortedApi.md + EndProjectSection EndProject Project("{2150E333-8FDC-42A3-9474-1A3956D46DE8}") = "Components", "Components", "{796792A1-0557-4BCF-832C-61A698C190DD}" ProjectSection(SolutionItems) = preProject diff --git a/Directory.Build.props b/Directory.Build.props index ba6f00d..f7dbf59 100644 --- a/Directory.Build.props +++ b/Directory.Build.props @@ -41,6 +41,10 @@ all runtime; build; native; contentfiles; analyzers; buildtransitive + + all + runtime; build; native; contentfiles; analyzers + diff --git a/Directory.Packages.props b/Directory.Packages.props index a7f82bd..e53efe9 100644 --- a/Directory.Packages.props +++ b/Directory.Packages.props @@ -4,6 +4,7 @@ + @@ -13,7 +14,7 @@ - + diff --git a/README.md b/README.md index 422c7c0..e69e922 100644 --- a/README.md +++ b/README.md @@ -8,6 +8,10 @@ library contains some basic and composites components which use [Azure Communication Services](https://azure.microsoft.com/fr-fr/products/communication-services) for Chat and Calling features. +The API of the library try to match as much as possible the +[Azure Communication Services UI Library API](https://azure.github.io/communication-ui-library/?path=/story/overview--page) +with the concept of [composite adapters](https://azure.github.io/communication-ui-library/?path=/docs/composite-adapters--page). + ## Demo project Do not hesitate to run the [tests/Communication.UI.Blazor.Demo](./tests/Communication.UI.Blazor.Demo) application which contains an example usage of the @@ -60,3 +64,7 @@ library. The library is based on the minimal ASP .NET Core Blazor version which is the 8.0.0 and can be used for the application based on this version or higher. + +## Ported APIs + +The list of the ported API is available in the [Ported API](./docs/PortedApi.md) page. \ No newline at end of file diff --git a/docs/Components/CallComposite.md b/docs/Components/CallComposite.md index 095dfda..fb771d5 100644 --- a/docs/Components/CallComposite.md +++ b/docs/Components/CallComposite.md @@ -8,22 +8,35 @@ This component is a wrapper of the library. To use the component: -- Add the `CallComposite` component. -- Define a reference to the component which allows to call the `LoadAsync()` -method to load the component with the token and ID of the Azure Communication Services to use. +- Register required services by calling the `AddCalling()` method in the main entry of the Blazor application. + +```csharp +builder.Services.AddCalling(); +``` + +- Inject the `ICallingService` dependency a use it to create an instance of `ICallAdapter`. +- Add the `CallComposite` component and bind the `Adapter` property with the `ICallAdapter` previously created. Example: ```razor - - +@inject ICallingService CallingService + + -@{ - private CallComposite? callComposite; +@code +{ + private ICallAdapter? callAdapter; private async Task LoadAsync() { @@ -33,32 +46,25 @@ Example: new TokenCredential("The ACS token")) { DisplayName = "John doe", - Options = - { - CallControls = - { - CameraButton = true, - DevicesButton = true, - EndCallButton = true, - MicrophoneButton = true, - MoreButton = true, - ParticipantsButton = true, - PeopleButton = true, - RaiseHandButton = true, - ScreenShareButton = true, - } - } }; - await this.callComposite!.LoadAsync(args); + this.callAdapter = await this.CallingService.CreateAdapterAsync(args); + + this.callAdapter.OnCallEnded += this.OnCallEnded; + this.callAdapter.OnParticipantJoined += this.OnParticipantJoined; + this.callAdapter.OnParticipantLeft += this.OnParticipantLeft; } } ``` -### Join the call -After the component has been loaded (or after leaving a call), it is possible to join the call -by calling the `JoinCall()` method. You can define if the camera and/or the microphone have to be -activated. +You can manage the `CallComposite` component using the `ICallAdapter` associated. For example, you can +subscribe to different events using a simple delegate. + +### Join/Leave the call +After the `ICallAdapter` has been associated to the `CallComposite` component +(or after leaving a call), it is possible to join the call +by calling the `JoinCall()` method on the `ICallAdapter`. +You can define if the camera and/or the microphone have to be activated. ```csharp private async Task JoinCallAsync() @@ -69,12 +75,36 @@ private async Task JoinCallAsync() MicrophoneOn = true, }; - await this.callComposite!.JoinCallAsync(options); + await this.callAdapter!.JoinCallAsync(options); } ``` +To leave the call, call the `LeaveCallAsync()` method on the `ICallAdapter`. This method +take a boolean parameter `forEveryone` to remove all participants when leaving. + +### Start/Stop screen share +To start sharing the screen on the current device, call the `StartScreenShare()` method on the `ICallAdapter`. + +To stop sharing the screen on the current device, call the `StopScreenShare()` method on the `ICallAdapter`. + +### Mute/Unmute +To mute the microphone of the current user, call the `MuteAsync()` method on the `ICallAdapter`. + +To unmute the microphone of the current user, call the `UnmuteAsync()` method on the `ICallAdapter`. + ### Events -You can subsribe to the following 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. \ No newline at end of file +- `OnParticipantLeft`: Occurs when a participant leave the call. + +### Dispose the resources +It is recommanded to implement the `IAsyncDisposable` method in the class which create +and manage the `ICallAdapter` instance. + +### Unit tests +The `ICallingService.CreateAdapterAsync()` method returns an instance of `ICallAdapter` +implemented by the `CallAdapter`. By returning interface implementation, developers +have no excuses to perform some units in their code by mocking the `ICallingService` +and `ICallAdapter` interfaces. \ No newline at end of file diff --git a/docs/PortedApi.md b/docs/PortedApi.md new file mode 100644 index 0000000..1f0caab --- /dev/null +++ b/docs/PortedApi.md @@ -0,0 +1,70 @@ +## Ported API + +This section contains the list of the APIs from the +[Adapters for Composites documentation](https://azure.github.io/communication-ui-library/?path=/docs/composite-adapters--page) +which has been ported to this library. + +### CallAdapter + +#### Methods + +| Method | Available | Remarks | +|-------------------------------|------------|------------------------------------------------------| +| onStateChange | TODO | | +| offStateChange | TODO | | +| getState | TODO | | +| dispose | **Done** | | +| holdCall (Beta) | No | Currently in beta | +| joinCall (Deprecated) | No | Deprecated | +| joinCall | Partially | Need to wrap the Call returned object | +| leaveCall | **Done** | | +| resumeCall (Beta) | No | Currently in beta | +| startCamera | TODO | | +| stopCamera | TODO | | +| mute | **Done** | | +| unmute | **Done** | | +| startCall (Beta) | No | Currently in beta | +| startScreenShare | **Done** | | +| stopScreenShare | **Done** | | +| addParticipant (Beta) | No | Currently in beta | +| removeParticipant | TODO | | +| createStreamView | TODO | | +| disposeStreamView | TODO | | +| askDevicePermission | TODO | | +| queryCameras | TODO | | +| queryMicrophones | TODO | | +| querySpeakers | TODO | | +| setCamera | TODO | | +| setMicrophone | TODO | | +| setSpeaker | TODO | | +| startCaptions | TODO | | +| stopCaptions | TODO | | +| raiseHand | TODO | | +| lowerHand | TODO | | +| setCaptionLanguage | TODO | | +| setSpokenLanguage | TODO | | +| submitSurvey | TODO | | +| startVideoBackgroundEffect | TODO | | +| stopVideoBackgroundEffects | TODO | | +| updateBackgroundPickerImages | TODO | | +| updateSelectedVideoBackgroundEffect | TODO | | + + +#### Events +| Name | Available | Remarks | +|-----------------------------------|-----------|---------| +| participantsJoined | **Done** | | +| participantsLeft | **Done** | | +| isMutedChanged | **Done** | | +| callIdChanged | TODO | | +| isLocalScreenSharingActiveChanged | TODO | | +| displayNameChanged | TODO | | +| isSpeakingChanged | TODO | | +| callEnded | **Done** | | +| diagnosticChanged | TODO | | +| error | TODO | | +| captionsReceived | TODO | | +| isCaptionsActiveChanged | TODO | | +| transferAccepted | TODO | | +| capabilitiesChanged | TODO | | +| spotlightChanged | TODO | | diff --git a/src/Communication.UI.Blazor/AsyncEventHandler.cs b/src/Communication.UI.Blazor/AsyncEventHandler.cs new file mode 100644 index 0000000..f2fd84d --- /dev/null +++ b/src/Communication.UI.Blazor/AsyncEventHandler.cs @@ -0,0 +1,16 @@ +//----------------------------------------------------------------------- +// +// Copyright (c) P.O.S Informatique. All rights reserved. +// +//----------------------------------------------------------------------- + +namespace PosInformatique.Azure.Communication.UI.Blazor +{ + /// + /// Represents the method that will handle asynchronously an event when the event provides data. + /// + /// The type of the event data generated by the event. + /// An object that contains the event data. + /// A which represents the asynchronous operation. + public delegate Task AsyncEventHandler(TEvent @event); +} diff --git a/src/Communication.UI.Blazor/Calling/CallAdapter.cs b/src/Communication.UI.Blazor/Calling/CallAdapter.cs new file mode 100644 index 0000000..7bb347b --- /dev/null +++ b/src/Communication.UI.Blazor/Calling/CallAdapter.cs @@ -0,0 +1,183 @@ +//----------------------------------------------------------------------- +// +// Copyright (c) P.O.S Informatique. All rights reserved. +// +//----------------------------------------------------------------------- + +namespace PosInformatique.Azure.Communication.UI.Blazor +{ + using Microsoft.JSInterop; + + /// + /// An adapter interface specific for Azure Communication identity which extends . + /// + public class CallAdapter : CommonCallAdapter, ICallAdapter, IDisposable + { + private CallbackEvent? callbackEvent; + + /// + /// Initializes a new instance of the class. + /// + internal CallAdapter(IJSObjectReference module) + { + this.Module = module; + this.Id = Guid.NewGuid(); + + this.callbackEvent = new CallbackEvent(this); + } + + /// + public event AsyncEventHandler? OnCallEnded; + + /// + public event AsyncEventHandler? OnMicrophoneMuteChanged; + + /// + public event AsyncEventHandler? OnParticipantJoined; + + /// + public event AsyncEventHandler? OnParticipantLeft; + + internal Guid Id { get; } + + internal IJSObjectReference Module { get; } + + /// + public async Task JoinCallAsync(JoinCallOptions options) + { + ObjectDisposedException.ThrowIf(this.callbackEvent is null, this); + + await this.Module.InvokeVoidAsync("adapterJoinCall", this.Id, options); + } + + /// + public async Task LeaveCallAsync(bool forEveryone) + { + ObjectDisposedException.ThrowIf(this.callbackEvent is null, this); + + await this.Module.InvokeVoidAsync("adapterLeaveCall", this.Id, forEveryone); + } + + /// + public async Task MuteAsync() + { + ObjectDisposedException.ThrowIf(this.callbackEvent is null, this); + + await this.Module.InvokeVoidAsync("adapterMute", this.Id); + } + + /// + public async Task UnmuteAsync() + { + ObjectDisposedException.ThrowIf(this.callbackEvent is null, this); + + await this.Module.InvokeVoidAsync("adapterUnmute", this.Id); + } + + /// + public async Task StartScreenShareAsync() + { + ObjectDisposedException.ThrowIf(this.callbackEvent is null, this); + + await this.Module.InvokeVoidAsync("adapterStartScreenShare", this.Id); + } + + /// + public async Task StopScreenShareAsync() + { + ObjectDisposedException.ThrowIf(this.callbackEvent is null, this); + + await this.Module.InvokeVoidAsync("adapterStopScreenShare", 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); + } + + private class CallbackEvent : IDisposable + { + private readonly CallAdapter owner; + + public CallbackEvent(CallAdapter owner) + { + this.owner = owner; + this.Reference = DotNetObjectReference.Create(this); + } + + public DotNetObjectReference? Reference { get; private set; } + + public void Dispose() + { + if (this.Reference != null) + { + this.Reference.Dispose(); + this.Reference = null; + } + } + + [JSInvokable] + public async Task OnCallEndedAsync(CallEndedEvent @event) + { + if (this.owner.OnCallEnded is not null) + { + await this.owner.OnCallEnded(@event); + } + } + + [JSInvokable] + public async Task OnMicrophoneMuteChangedAsync(MicrophoneMuteChangedEvent @event) + { + if (this.owner.OnMicrophoneMuteChanged is not null) + { + await this.owner.OnMicrophoneMuteChanged(@event); + } + } + + [JSInvokable] + public async Task OnParticipantsJoinedAsync(RemoteParticipant[] joined) + { + if (this.owner.OnParticipantJoined is not null) + { + foreach (var participant in joined) + { + await this.owner.OnParticipantJoined(new RemoteParticipantJoinedEvent(participant)); + } + } + } + + [JSInvokable] + public async Task OnParticipantsLeftAsync(RemoteParticipant[] removed) + { + if (this.owner.OnParticipantLeft is not null) + { + foreach (var participant in removed) + { + await this.owner.OnParticipantLeft(new RemoteParticipantLeftEvent(participant)); + } + } + } + } + } +} diff --git a/src/Communication.UI.Blazor/Calling/CallAdapterArgs.cs b/src/Communication.UI.Blazor/Calling/CallAdapterArgs.cs index 9e321f7..817e3c1 100644 --- a/src/Communication.UI.Blazor/Calling/CallAdapterArgs.cs +++ b/src/Communication.UI.Blazor/Calling/CallAdapterArgs.cs @@ -25,7 +25,6 @@ public CallAdapterArgs(UserIdentifier userId, GroupCallLocator locator, TokenCre this.DisplayName = "Anonymous"; this.Locator = locator; this.Credential = credential; - this.Options = new CallCompositeOptions(); } /// @@ -55,12 +54,5 @@ public CallAdapterArgs(UserIdentifier userId, GroupCallLocator locator, TokenCre [JsonPropertyName("locator")] [JsonPropertyOrder(4)] public GroupCallLocator Locator { get; } - - /// - /// Gets the options of the . - /// - [JsonPropertyName("options")] - [JsonPropertyOrder(5)] - public CallCompositeOptions Options { get; } } } diff --git a/src/Communication.UI.Blazor/Calling/CallComposite.razor.cs b/src/Communication.UI.Blazor/Calling/CallComposite.razor.cs index ea75f82..0b65cc6 100644 --- a/src/Communication.UI.Blazor/Calling/CallComposite.razor.cs +++ b/src/Communication.UI.Blazor/Calling/CallComposite.razor.cs @@ -12,165 +12,116 @@ namespace PosInformatique.Azure.Communication.UI.Blazor /// /// Blazor component used wrap the CallComposite of Microsoft Azure Communication Services UI library. /// - public sealed partial class CallComposite : IAsyncDisposable, IDisposable + public sealed partial class CallComposite { - private IJSObjectReference? module; - - private CallbackEvent? callbackEvent; + private static readonly CallControlOptions DefaultOptions = new CallControlOptions(); private ElementReference callContainer; /// - /// Initializes a new instance of the class. + /// Gets or sets the which provides the logic and data of the composite control. + /// The can also be controlled using the adapter. /// - public CallComposite() - { - this.callbackEvent = new CallbackEvent(this); - } + [Parameter] + [EditorRequired] + public ICallAdapter? Adapter { get; set; } /// - /// Gets or sets the used to manage Microsoft JS CallComposite component. + /// Gets or sets a value indicating whether to + /// show or hide Camera Button during a call. + /// Default value: . /// - [Inject] - public IJSRuntime JSRuntime { get; set; } = default!; + [Parameter] + public bool CameraButton { get; set; } = DefaultOptions.CameraButton; /// - /// Gets or sets a callback when the call is ended. + /// Gets or sets a value indicating whether to + /// show or hide Devices button during a call. + /// Default value: . /// [Parameter] - public EventCallback OnCallEnded { get; set; } + public bool DevicesButton { get; set; } = DefaultOptions.DevicesButton; /// - /// Gets or sets a callback when a participant join the call. + /// Gets or sets a value indicating whether to + /// show or hide EndCall button during a call. + /// Default value: . /// [Parameter] - public EventCallback OnParticipantJoined { get; set; } + public bool EndCallButton { get; set; } = DefaultOptions.EndCallButton; /// - /// Gets or sets a callback when a participant leave the call. + /// Gets or sets a value indicating whether to + /// show or hide Microphone button during a call. + /// Default value: . /// [Parameter] - public EventCallback OnParticipantLeft { get; set; } + public bool MicrophoneButton { get; set; } = DefaultOptions.MicrophoneButton; /// - /// Gets a value indicating whether if the component has been loaded with the - /// method. + /// Gets or sets a value indicating whether to + /// show, hide or disable the more button during a call. + /// Default value: . /// - public bool IsLoaded { get; private set; } + [Parameter] + public bool MoreButton { get; set; } = DefaultOptions.MoreButton; /// - /// Loads the composite component using the specified arguments. + /// Gets or sets a value indicating whether to + /// show, hide or disable participants button during a call. + /// Default value: . /// - /// Parameters of the composite component. - /// A of the asynchronous operation. - public async Task LoadAsync(CallAdapterArgs args) - { - ObjectDisposedException.ThrowIf(this.callbackEvent is null, this); - - await this.EnsureModuleLoadAsync(); - - await this.module!.InvokeVoidAsync("initialize", this.callContainer, args, this.callbackEvent.Reference); - - this.IsLoaded = true; - } + [Parameter] + public bool ParticipantsButton { get; set; } = DefaultOptions.ParticipantsButton; /// - /// Join an existing call. + /// Gets or sets a value indicating whether to + /// show, hide or disable the people button during a call. + /// Default value: . /// - /// Options of the call. - /// A of the asynchronous operation. - /// If the component has not been loaded. - public async Task JoinCallAsync(JoinCallOptions options) - { - ObjectDisposedException.ThrowIf(this.callbackEvent is null, this); - - if (!this.IsLoaded) - { - throw new InvalidOperationException("The component has not been loaded. Ensures that the LoadAsync() method has been called first."); - } - - await this.EnsureModuleLoadAsync(); - - await this.module!.InvokeVoidAsync("adapterJoinCall", this.callContainer, options); - } - - /// - public async ValueTask DisposeAsync() - { - if (this.module != null) - { - await this.module.InvokeVoidAsync("dispose", this.callContainer); - await this.module.DisposeAsync(); + [Parameter] + public bool PeopleButton { get; set; } = DefaultOptions.PeopleButton; - this.module = null; - } + /// + /// Gets or sets a value indicating whether to + /// show, hide or disable the raise hand button during a call. + /// Default value: . + /// + [Parameter] + public bool RaiseHandButton { get; set; } = DefaultOptions.RaiseHandButton; - this.Dispose(); - } + /// + /// Gets or sets a value indicating whether to + /// show, hide or disable the screen share button during a call. + /// Default value: . + /// + [Parameter] + public bool ScreenShareButton { get; set; } = DefaultOptions.ScreenShareButton; /// - public void Dispose() - { - if (this.callbackEvent != null) - { - this.callbackEvent.Dispose(); - this.callbackEvent = null; - } - } - - private async Task EnsureModuleLoadAsync() + protected override async Task OnAfterRenderAsync(bool firstRender) { - if (this.module is null) + if (this.Adapter is not null) { - this.module = await this.JSRuntime.InvokeAsync( - "import", - "./_content/PosInformatique.Azure.Communication.UI.Blazor/Calling/CallComposite.razor.js"); - } - } - - private class CallbackEvent : IDisposable - { - private readonly CallComposite owner; - - public CallbackEvent(CallComposite owner) - { - this.owner = owner; - this.Reference = DotNetObjectReference.Create(this); - } - - public DotNetObjectReference? Reference { get; private set; } - - public void Dispose() - { - if (this.Reference != null) - { - this.Reference.Dispose(); - this.Reference = null; - } - } - - [JSInvokable] - public async Task OnCallEndedAsync(CallAdapterCallEndedEvent @event) - { - await this.owner.OnCallEnded.InvokeAsync(@event); - } - - [JSInvokable] - public async Task OnParticipantsJoinedAsync(RemoteParticipant[] joined) - { - foreach (var participant in joined) + if (this.Adapter is not CallAdapter adapter) { - await this.owner.OnParticipantJoined.InvokeAsync(new RemoteParticipantJoinedEvent(participant)); + throw new InvalidOperationException("The Adapter property must an instance of the CallAdapter class."); } - } - [JSInvokable] - public async Task OnParticipantsLeftAsync(RemoteParticipant[] removed) - { - foreach (var participant in removed) + var options = new CallControlOptions() { - await this.owner.OnParticipantLeft.InvokeAsync(new RemoteParticipantLeftEvent(participant)); - } + CameraButton = this.CameraButton, + DevicesButton = this.DevicesButton, + EndCallButton = this.EndCallButton, + MicrophoneButton = this.MicrophoneButton, + MoreButton = this.MoreButton, + ParticipantsButton = this.ParticipantsButton, + PeopleButton = this.PeopleButton, + RaiseHandButton = this.RaiseHandButton, + ScreenShareButton = this.ScreenShareButton, + }; + + await adapter.Module.InvokeVoidAsync("initializeControl", this.callContainer, adapter.Id, options); } } } diff --git a/src/Communication.UI.Blazor/Calling/CallComposite.razor.js b/src/Communication.UI.Blazor/Calling/CallComposite.razor.js index 9ffc27c..5d1747e 100644 --- a/src/Communication.UI.Blazor/Calling/CallComposite.razor.js +++ b/src/Communication.UI.Blazor/Calling/CallComposite.razor.js @@ -7,11 +7,11 @@ initializeIcons, } from '/_content/PosInformatique.Azure.Communication.UI.Blazor/azure-communication-react-bundle.js' -export async function initialize(divElement, args, eventCallback) { +initializeIcons(undefined, { disableWarnings: true }); - initializeIcons(undefined, { disableWarnings: true }); +export async function createCallAdapter(id, args, eventCallback) { - divElement.adapter = await createAzureCommunicationCallAdapter({ + var adapter = await createAzureCommunicationCallAdapter({ userId: args.userId, displayName: args.displayName, credential: new AzureCommunicationTokenCredential(args.credential.token), @@ -19,30 +19,97 @@ export async function initialize(divElement, args, eventCallback) { options: args.options }); - createRoot(divElement).render(createElement(CallComposite, { ...args, adapter: divElement.adapter }, null)); - divElement.adapter.on('callEnded', (event) => { + adapter.on('callEnded', (event) => { return eventCallback.invokeMethodAsync('OnCallEndedAsync', event); }); - divElement.adapter.on('participantsJoined', (event) => { + adapter.on('isMutedChanged', (event) => { + return eventCallback.invokeMethodAsync('OnMicrophoneMuteChangedAsync', event); + }); + + adapter.on('participantsJoined', (event) => { return eventCallback.invokeMethodAsync('OnParticipantsJoinedAsync', event.joined.map(createRemoteParticipant)); }); - divElement.adapter.on('participantsLeft', (event) => { + adapter.on('participantsLeft', (event) => { return eventCallback.invokeMethodAsync('OnParticipantsLeftAsync', event.removed.map(createRemoteParticipant)); }); + + registerAdapter(id, adapter); +} + +export function initializeControl(divElement, adapterId, callControls) { + + var adapter = getAdapter(adapterId); + + var element = createElement(CallComposite, { options: { callControls: callControls }, adapter: adapter }, null); + + createRoot(divElement).render(element); +} + +export function adapterJoinCall(id, options) { + + const adapter = getAdapter(id); + + adapter.joinCall(options); +} + +export async function adapterLeaveCall(id, forEveryone) { + + const adapter = getAdapter(id); + + await adapter.leaveCall(forEveryone); } -export function adapterJoinCall(divElement, options) { - divElement.adapter.joinCall(options); +export async function adapterMute(id) { + + const adapter = getAdapter(id); + + await adapter.mute(); } -export function dispose(divElement) { - if (divElement.adapter != null) { - divElement.adapter.dispose(); - divElement.adapter = null; +export async function adapterUnmute(id) { + + const adapter = getAdapter(id); + + await adapter.unmute(); +} + +export async function adapterStartScreenShare(id) { + + const adapter = getAdapter(id); + + await adapter.startScreenShare(); +} + +export async function adapterStopScreenShare(id) { + + const adapter = getAdapter(id); + + await adapter.stopScreenShare(); +} + +export function dispose(id) { + + const adapter = getAdapter(id); + + adapter.dispose(); + + delete window.__posInfo_azure_comm_ui_blazor[id]; +} + +function getAdapter(id) { + return window.__posInfo_azure_comm_ui_blazor[id]; +} + +function registerAdapter(id, adapter) { + + if (typeof window.__posInfo_azure_comm_ui_blazor == "undefined") { + window.__posInfo_azure_comm_ui_blazor = {}; } + + window.__posInfo_azure_comm_ui_blazor[id] = adapter; } function createRemoteParticipant(remoteParticipant) { diff --git a/src/Communication.UI.Blazor/Calling/CallCompositeOptions.cs b/src/Communication.UI.Blazor/Calling/CallCompositeOptions.cs deleted file mode 100644 index 6118473..0000000 --- a/src/Communication.UI.Blazor/Calling/CallCompositeOptions.cs +++ /dev/null @@ -1,31 +0,0 @@ -//----------------------------------------------------------------------- -// -// Copyright (c) P.O.S Informatique. All rights reserved. -// -//----------------------------------------------------------------------- - -namespace PosInformatique.Azure.Communication.UI.Blazor -{ - using System.Text.Json.Serialization; - - /// - /// Optional features of the . - /// - public class CallCompositeOptions - { - /// - /// Initializes a new instance of the class. - /// - public CallCompositeOptions() - { - this.CallControls = new CallControlOptions(); - } - - /// - /// Gets the customization options for the control bar in calling experience. - /// - [JsonPropertyOrder(1)] - [JsonPropertyName("callControls")] - public CallControlOptions CallControls { get; } - } -} diff --git a/src/Communication.UI.Blazor/Calling/CallControlOptions.cs b/src/Communication.UI.Blazor/Calling/CallControlOptions.cs index bc2b7d9..c454ada 100644 --- a/src/Communication.UI.Blazor/Calling/CallControlOptions.cs +++ b/src/Communication.UI.Blazor/Calling/CallControlOptions.cs @@ -9,7 +9,7 @@ namespace PosInformatique.Azure.Communication.UI.Blazor /// /// Customization options for the control bar in calling experience. /// - public class CallControlOptions : CommonCallControlOptions + internal class CallControlOptions : CommonCallControlOptions { /// /// Initializes a new instance of the class. diff --git a/src/Communication.UI.Blazor/Calling/CallingService.cs b/src/Communication.UI.Blazor/Calling/CallingService.cs new file mode 100644 index 0000000..323575c --- /dev/null +++ b/src/Communication.UI.Blazor/Calling/CallingService.cs @@ -0,0 +1,63 @@ +//----------------------------------------------------------------------- +// +// Copyright (c) P.O.S Informatique. All rights reserved. +// +//----------------------------------------------------------------------- + +namespace PosInformatique.Azure.Communication.UI.Blazor +{ + using Microsoft.JSInterop; + + /// + /// Implementation of the used to create instances + /// of the . + /// + public sealed class CallingService : ICallingService, IAsyncDisposable + { + private readonly IJSRuntime jsRuntime; + + private IJSObjectReference? module; + + /// + /// Initializes a new instance of the class. + /// + /// used to interop with JavaScript. + public CallingService(IJSRuntime jsRuntime) + { + this.jsRuntime = jsRuntime; + } + + /// + public async ValueTask DisposeAsync() + { + if (this.module != null) + { + await this.module.DisposeAsync(); + + this.module = null; + } + } + + /// + public async Task CreateAdapterAsync(CallAdapterArgs args) + { + await this.EnsureModuleLoadAsync(); + + var adapter = new CallAdapter(this.module!); + + await adapter.InitializeAsync(args); + + return adapter; + } + + private async Task EnsureModuleLoadAsync() + { + if (this.module is null) + { + this.module = await this.jsRuntime.InvokeAsync( + "import", + "./_content/PosInformatique.Azure.Communication.UI.Blazor/Calling/CallComposite.razor.js"); + } + } + } +} diff --git a/src/Communication.UI.Blazor/Calling/CallingServiceCollectionExtensions.cs b/src/Communication.UI.Blazor/Calling/CallingServiceCollectionExtensions.cs new file mode 100644 index 0000000..e4f535a --- /dev/null +++ b/src/Communication.UI.Blazor/Calling/CallingServiceCollectionExtensions.cs @@ -0,0 +1,26 @@ +//----------------------------------------------------------------------- +// +// Copyright (c) P.O.S Informatique. All rights reserved. +// +//----------------------------------------------------------------------- + +namespace Microsoft.Extensions.DependencyInjection +{ + using PosInformatique.Azure.Communication.UI.Blazor; + + /// + /// Contains extensions method to register Azure Communication Services UI Library services. + /// + public static class CallingServiceCollectionExtensions + { + /// + /// Add Azure Communication Services services used for the calling operation. + /// + /// where the services will be registered. + /// The instance to continue to register additionnal services. + public static IServiceCollection AddCalling(this IServiceCollection services) + { + return services.AddSingleton(); + } + } +} diff --git a/src/Communication.UI.Blazor/Calling/CommonCallAdapter.cs b/src/Communication.UI.Blazor/Calling/CommonCallAdapter.cs new file mode 100644 index 0000000..cd6c5c6 --- /dev/null +++ b/src/Communication.UI.Blazor/Calling/CommonCallAdapter.cs @@ -0,0 +1,21 @@ +//----------------------------------------------------------------------- +// +// Copyright (c) P.O.S Informatique. All rights reserved. +// +//----------------------------------------------------------------------- + +namespace PosInformatique.Azure.Communication.UI.Blazor +{ + /// + /// adapter interface. + /// + public abstract class CommonCallAdapter + { + /// + /// Initializes a new instance of the class. + /// + protected CommonCallAdapter() + { + } + } +} diff --git a/src/Communication.UI.Blazor/Calling/CommonCallControlOptions.cs b/src/Communication.UI.Blazor/Calling/CommonCallControlOptions.cs index 116a31c..9a7bb83 100644 --- a/src/Communication.UI.Blazor/Calling/CommonCallControlOptions.cs +++ b/src/Communication.UI.Blazor/Calling/CommonCallControlOptions.cs @@ -11,7 +11,7 @@ namespace PosInformatique.Azure.Communication.UI.Blazor /// /// Customization options for the control bar in calling experience. /// - public abstract class CommonCallControlOptions + internal abstract class CommonCallControlOptions { /// /// Initializes a new instance of the class. diff --git a/src/Communication.UI.Blazor/Calling/CallAdapterCallEndedEvent.cs b/src/Communication.UI.Blazor/Calling/Events/CallEndedEvent.cs similarity index 74% rename from src/Communication.UI.Blazor/Calling/CallAdapterCallEndedEvent.cs rename to src/Communication.UI.Blazor/Calling/Events/CallEndedEvent.cs index a01a00d..cf092ed 100644 --- a/src/Communication.UI.Blazor/Calling/CallAdapterCallEndedEvent.cs +++ b/src/Communication.UI.Blazor/Calling/Events/CallEndedEvent.cs @@ -1,5 +1,5 @@ //----------------------------------------------------------------------- -// +// // Copyright (c) P.O.S Informatique. All rights reserved. // //----------------------------------------------------------------------- @@ -11,13 +11,13 @@ namespace PosInformatique.Azure.Communication.UI.Blazor /// /// Contains information when a call is ended. /// - public class CallAdapterCallEndedEvent + public class CallEndedEvent { /// - /// Initializes a new instance of the class. + /// Initializes a new instance of the class. /// /// Identifier of the call ended. - public CallAdapterCallEndedEvent(string callId) + public CallEndedEvent(string callId) { this.CallId = callId; } diff --git a/src/Communication.UI.Blazor/Calling/Events/MicrophoneMuteChangedEvent.cs b/src/Communication.UI.Blazor/Calling/Events/MicrophoneMuteChangedEvent.cs new file mode 100644 index 0000000..0ba6ace --- /dev/null +++ b/src/Communication.UI.Blazor/Calling/Events/MicrophoneMuteChangedEvent.cs @@ -0,0 +1,35 @@ +//----------------------------------------------------------------------- +// +// Copyright (c) P.O.S Informatique. All rights reserved. +// +//----------------------------------------------------------------------- + +namespace PosInformatique.Azure.Communication.UI.Blazor +{ + /// + /// Event occured when the microphone is muted or unmuted on a participant. + /// + public class MicrophoneMuteChangedEvent + { + /// + /// Initializes a new instance of the class. + /// + /// Participant identifier which the microphone has been mute/unmute. + /// Indicating whether if the microphone is muted. + public MicrophoneMuteChangedEvent(CommunicationUserKind participantId, bool isMuted) + { + this.ParticipantId = participantId; + this.IsMuted = isMuted; + } + + /// + /// Gets the participant identifier which the microphone has been mute/unmute. + /// + public CommunicationUserKind ParticipantId { get; } + + /// + /// Gets a value indicating whether of the microphone is muted. + /// + public bool IsMuted { get; } + } +} diff --git a/src/Communication.UI.Blazor/Calling/RemoteParticipantJoinedEvent.cs b/src/Communication.UI.Blazor/Calling/Events/RemoteParticipantJoinedEvent.cs similarity index 100% rename from src/Communication.UI.Blazor/Calling/RemoteParticipantJoinedEvent.cs rename to src/Communication.UI.Blazor/Calling/Events/RemoteParticipantJoinedEvent.cs diff --git a/src/Communication.UI.Blazor/Calling/RemoteParticipantLeftEvent.cs b/src/Communication.UI.Blazor/Calling/Events/RemoteParticipantLeftEvent.cs similarity index 100% rename from src/Communication.UI.Blazor/Calling/RemoteParticipantLeftEvent.cs rename to src/Communication.UI.Blazor/Calling/Events/RemoteParticipantLeftEvent.cs diff --git a/src/Communication.UI.Blazor/Calling/ICallAdapter.cs b/src/Communication.UI.Blazor/Calling/ICallAdapter.cs new file mode 100644 index 0000000..6948d21 --- /dev/null +++ b/src/Communication.UI.Blazor/Calling/ICallAdapter.cs @@ -0,0 +1,80 @@ +//----------------------------------------------------------------------- +// +// Copyright (c) P.O.S Informatique. All rights reserved. +// +//----------------------------------------------------------------------- + +namespace PosInformatique.Azure.Communication.UI.Blazor +{ + using System.Threading.Tasks; + + /// + /// Adapter which allows to manage the component. + /// + public interface ICallAdapter : IAsyncDisposable + { + /// + /// Occurs when the call is ended. + /// + event AsyncEventHandler? OnCallEnded; + + /// + /// Occurs when the microphone is muted/unmuted on a participant. + /// + event AsyncEventHandler? OnMicrophoneMuteChanged; + + /// + /// Occurs when a participant join the call. + /// + event AsyncEventHandler? OnParticipantJoined; + + /// + /// Occurs when a participant leave the call. + /// + event AsyncEventHandler? OnParticipantLeft; + + /// + /// Join an existing call. + /// + /// Options of the call. + /// A that represents the asynchronous invocation. + /// If the has already been disposed. + Task JoinCallAsync(JoinCallOptions options); + + /// + /// Leave the call. + /// + /// Whether to remove all participants when leaving. + /// A that represents the asynchronous invocation. + /// If the has already been disposed. + Task LeaveCallAsync(bool forEveryone); + + /// + /// Mute the current user during the call or disable microphone locally. + /// + /// A that represents the asynchronous invocation. + /// If the has already been disposed. + Task MuteAsync(); + + /// + /// Start sharing the screen during a call. + /// + /// A that represents the asynchronous invocation. + /// If the has already been disposed. + Task StartScreenShareAsync(); + + /// + /// Stop sharing the screen. + /// + /// A that represents the asynchronous invocation. + /// If the has already been disposed. + Task StopScreenShareAsync(); + + /// + /// Unmute the current user during the call or enable microphone locally. + /// + /// A that represents the asynchronous invocation. + /// If the has already been disposed. + Task UnmuteAsync(); + } +} \ No newline at end of file diff --git a/src/Communication.UI.Blazor/Calling/ICallingService.cs b/src/Communication.UI.Blazor/Calling/ICallingService.cs new file mode 100644 index 0000000..67a9274 --- /dev/null +++ b/src/Communication.UI.Blazor/Calling/ICallingService.cs @@ -0,0 +1,23 @@ +//----------------------------------------------------------------------- +// +// Copyright (c) P.O.S Informatique. All rights reserved. +// +//----------------------------------------------------------------------- + +namespace PosInformatique.Azure.Communication.UI.Blazor +{ + /// + /// Service of the Calling Azure Communication Services feature + /// which allows to create instances. + /// + public interface ICallingService + { + /// + /// Create a backed by Azure Communication Services. + /// + /// Parameters of the to create. + /// A new instance of the which can be use by a using the + /// property. + Task CreateAdapterAsync(CallAdapterArgs args); + } +} diff --git a/src/Communication.UI.Blazor/Communication.UI.Blazor.csproj b/src/Communication.UI.Blazor/Communication.UI.Blazor.csproj index 7719b74..f1061af 100644 --- a/src/Communication.UI.Blazor/Communication.UI.Blazor.csproj +++ b/src/Communication.UI.Blazor/Communication.UI.Blazor.csproj @@ -11,6 +11,21 @@ https://github.com/PosInformatique/PosInformatique.Azure.Communication.UI.Blazor README.md + 1.1.0 + - Refactoring to separate the CallAdapter and the CallComposite to reflect the architecture of the Communication UI Library. + This refactoring required now a service which will let the developer to create the CallAdapter. + The events has been moved to the CallAdapter. + - The CallComposite have parameters to select the button to hide or display. + - Add the following APIs in the CallAdapter: + - LeaveCallAsync() + - MuteAsync() + - UnmuteAsync() + - StartScreenShareAsync() + - StopScreenShareAsync() + - Add the following events in the CallAdapter + - OnMicrophoneMuteChanged + - CallEnded + 1.0.0 - Initial version with the CallComposite component. diff --git a/src/Communication.UI.Blazor/stylecop.json b/src/Communication.UI.Blazor/stylecop.json index c0fe9eb..b747ad1 100644 --- a/src/Communication.UI.Blazor/stylecop.json +++ b/src/Communication.UI.Blazor/stylecop.json @@ -2,7 +2,8 @@ "settings": { "documentationRules": { "companyName": "P.O.S Informatique", - "copyrightText": "Copyright (c) {companyName}. All rights reserved." + "copyrightText": "Copyright (c) {companyName}. All rights reserved.", + "documentInternalElements": false } } } \ No newline at end of file diff --git a/tests/.editorconfig b/tests/.editorconfig index 1c943a1..c8f772f 100644 --- a/tests/.editorconfig +++ b/tests/.editorconfig @@ -25,3 +25,9 @@ dotnet_diagnostic.BL0005.severity = none # CA1861: Avoid constant arrays as arguments dotnet_diagnostic.CA1861.severity = none + +# VSTHRD103: Call async methods when in an async method +dotnet_diagnostic.VSTHRD103.severity = none + +# VSTHRD200: Use "Async" suffix for async methods +dotnet_diagnostic.VSTHRD200.severity = none diff --git a/tests/Communication.UI.Blazor.Demo/Pages/Home.razor b/tests/Communication.UI.Blazor.Demo/Pages/Home.razor index 58eaa64..38cf8d2 100644 --- a/tests/Communication.UI.Blazor.Demo/Pages/Home.razor +++ b/tests/Communication.UI.Blazor.Demo/Pages/Home.razor @@ -1,5 +1,7 @@ @page "/" @inject IdentityManager IdentityManager +@inject ICallingService CallingService +@implements IAsyncDisposable Calling @@ -48,17 +50,32 @@
- -
+ + + + + + + - + + + + - +
+

Logs (Using events):

@foreach (var message in this.log) { @@ -67,127 +84,4 @@

-@code { - private CallComposite? callComposite; - - private string userId; - - private string groupIdLocator; - - private string displayName; - - private bool cameraButton; - private bool devicesButton; - private bool endCallButton; - private bool microphoneButton; - private bool moreButton; - private bool participantsButton; - private bool peopleButton; - private bool raiseHandButton; - private bool screenShareButton; - - private List log; - - public Home() - { - this.userId = string.Empty; - this.groupIdLocator = "76FC6C87-2D4C-49C8-B909-7E3819A88621"; - this.displayName = "John Doe"; - - this.cameraButton = true; - this.devicesButton = true; - this.endCallButton = true; - this.microphoneButton = true; - this.moreButton = true; - this.participantsButton = true; - this.peopleButton = true; - this.raiseHandButton = true; - this.screenShareButton = true; - - this.log = new List(); - } - - public bool DisableLoad - { - get - { - if (string.IsNullOrWhiteSpace(this.userId)) - { - return true; - } - - if (string.IsNullOrWhiteSpace(this.groupIdLocator)) - { - return true; - } - return false; - } - } - - private async Task CreateUserAsync() - { - this.userId = await this.IdentityManager.CreateUserAsync(); - } - - private async Task LoadAsync() - { - var token = await this.IdentityManager.GetTokenAsync(this.userId); - - var args = new CallAdapterArgs( - new UserIdentifier(this.userId), - new GroupCallLocator(this.groupIdLocator), - new TokenCredential(token)) - { - DisplayName = this.displayName, - Options = - { - CallControls = - { - CameraButton = this.cameraButton, - DevicesButton = this.devicesButton, - EndCallButton = this.endCallButton, - MicrophoneButton = this.microphoneButton, - MoreButton = this.moreButton, - ParticipantsButton = this.participantsButton, - PeopleButton = this.peopleButton, - RaiseHandButton = this.raiseHandButton, - ScreenShareButton = this.screenShareButton, - } - } - }; - - await this.callComposite!.LoadAsync(args); - } - - private async Task JoinCallAsync() - { - var options = new JoinCallOptions() - { - CameraOn = true, - MicrophoneOn = true, - }; - - await this.callComposite!.JoinCallAsync(options); - } - - private void OnCallEnded() - { - this.Log("Call ended"); - } - - private void OnParticipantJoined(RemoteParticipantJoinedEvent @event) - { - this.Log($"{@event.Participant.DisplayName} has join the call. (ID: {@event.Participant.Identifier.CommunicationUserId})"); - } - - private void OnParticipantLeft(RemoteParticipantLeftEvent @event) - { - this.Log($"{@event.Participant.DisplayName} has left the call. (ID: {@event.Participant.Identifier.CommunicationUserId})"); - } - - private void Log(string message) - { - this.log.Add(message); - } -} diff --git a/tests/Communication.UI.Blazor.Demo/Pages/Home.razor.cs b/tests/Communication.UI.Blazor.Demo/Pages/Home.razor.cs new file mode 100644 index 0000000..a9eabf2 --- /dev/null +++ b/tests/Communication.UI.Blazor.Demo/Pages/Home.razor.cs @@ -0,0 +1,198 @@ +//----------------------------------------------------------------------- +// +// Copyright (c) P.O.S Informatique. All rights reserved. +// +//----------------------------------------------------------------------- + +namespace PosInformatique.Azure.Communication.UI.Blazor.Demo.Pages +{ + using System.Collections.Generic; + using Microsoft.AspNetCore.Components; + + public partial class Home + { + private readonly List log; + + private ICallAdapter? callAdapter; + + private string userId; + + private string groupIdLocator; + + private string displayName; + + private bool cameraButton; + private bool devicesButton; + private bool endCallButton; + private bool microphoneButton; + private bool moreButton; + private bool participantsButton; + private bool peopleButton; + private bool raiseHandButton; + private bool screenShareButton; + + private bool leaveCallForEveryone; + + public Home() + { + this.userId = string.Empty; + this.groupIdLocator = "76FC6C87-2D4C-49C8-B909-7E3819A88621"; + this.displayName = "John Doe"; + + this.cameraButton = true; + this.devicesButton = true; + this.endCallButton = true; + this.microphoneButton = true; + this.moreButton = true; + this.participantsButton = true; + this.peopleButton = true; + this.raiseHandButton = true; + this.screenShareButton = true; + + this.log = []; + } + + [Inject] + public IConfiguration Configuration { get; set; } = default!; + + public bool DisableLoad + { + get + { + if (string.IsNullOrWhiteSpace(this.userId)) + { + return true; + } + + if (string.IsNullOrWhiteSpace(this.groupIdLocator)) + { + return true; + } + + return false; + } + } + + public async ValueTask DisposeAsync() + { + if (this.callAdapter is not null) + { + await this.callAdapter.DisposeAsync(); + + this.callAdapter = null; + } + + GC.SuppressFinalize(this); + } + + protected override void OnInitialized() + { + if (!string.IsNullOrWhiteSpace(this.Configuration["DEBUG_DEFAULT_USERID"])) + { + this.userId = this.Configuration["DEBUG_DEFAULT_USERID"]!; + } + } + + private async Task CreateUserAsync() + { + this.userId = await this.IdentityManager.CreateUserAsync(); + } + + private async Task LoadAsync() + { + var token = await this.IdentityManager.GetTokenAsync(this.userId); + + var args = new CallAdapterArgs( + new UserIdentifier(this.userId), + new GroupCallLocator(this.groupIdLocator), + new TokenCredential(token)) + { + DisplayName = this.displayName, + }; + + if (this.callAdapter is not null) + { + await this.callAdapter.DisposeAsync(); + this.callAdapter = null; + } + + this.callAdapter = await this.CallingService.CreateAdapterAsync(args); + + this.callAdapter.OnCallEnded += this.OnCallEnded; + this.callAdapter.OnMicrophoneMuteChanged += this.OnMicrophoneMuteChanged; + this.callAdapter.OnParticipantJoined += this.OnParticipantJoined; + this.callAdapter.OnParticipantLeft += this.OnParticipantLeft; + } + + private async Task MuteAsync() + { + await this.callAdapter!.MuteAsync(); + } + + private async Task UnmuteAsync() + { + await this.callAdapter!.UnmuteAsync(); + } + + private async Task StartScreenShareAsync() + { + await this.callAdapter!.StartScreenShareAsync(); + } + + private async Task StopScreenShareAsync() + { + await this.callAdapter!.StopScreenShareAsync(); + } + + private async Task JoinCallAsync() + { + var options = new JoinCallOptions() + { + CameraOn = true, + MicrophoneOn = true, + }; + + await this.callAdapter!.JoinCallAsync(options); + } + + private async Task LeaveCallAsync() + { + await this.callAdapter!.LeaveCallAsync(this.leaveCallForEveryone); + } + + private async Task OnCallEnded(CallEndedEvent @event) + { + this.Log($"Call ended (CallId: {@event.CallId})"); + + await Task.CompletedTask; + } + + private async Task OnMicrophoneMuteChanged(MicrophoneMuteChangedEvent @event) + { + this.Log($"Microphone mute changed. (IsMuted: {@event.IsMuted}, ParticipantId: {@event.ParticipantId.CommunicationUserId})"); + + await Task.CompletedTask; + } + + private async Task OnParticipantJoined(RemoteParticipantJoinedEvent @event) + { + this.Log($"{@event.Participant.DisplayName} has join the call. (ID: {@event.Participant.Identifier.CommunicationUserId})"); + + await Task.CompletedTask; + } + + private async Task OnParticipantLeft(RemoteParticipantLeftEvent @event) + { + this.Log($"{@event.Participant.DisplayName} has left the call. (ID: {@event.Participant.Identifier.CommunicationUserId})"); + + await Task.CompletedTask; + } + + private void Log(string message) + { + this.log.Add(message); + + this.StateHasChanged(); + } + } +} \ No newline at end of file diff --git a/tests/Communication.UI.Blazor.Demo/Program.cs b/tests/Communication.UI.Blazor.Demo/Program.cs index 8d18e42..816b3cd 100644 --- a/tests/Communication.UI.Blazor.Demo/Program.cs +++ b/tests/Communication.UI.Blazor.Demo/Program.cs @@ -27,6 +27,10 @@ public static async Task Main(string[] args) builder.Services.AddScoped(sp => new HttpClient { BaseAddress = new Uri(builder.HostEnvironment.BaseAddress) }); + // Required by the CallComposite component. + builder.Services.AddCalling(); + + // Used to create identity user and retrieve ACS tokens. builder.Services.AddSingleton(); builder.Services.Configure(opt => { diff --git a/tests/Communication.UI.Blazor.Tests/Calling/CallAdapterArgsTest.cs b/tests/Communication.UI.Blazor.Tests/Calling/CallAdapterArgsTest.cs index 518bdde..eab7429 100644 --- a/tests/Communication.UI.Blazor.Tests/Calling/CallAdapterArgsTest.cs +++ b/tests/Communication.UI.Blazor.Tests/Calling/CallAdapterArgsTest.cs @@ -20,7 +20,6 @@ public void Constructor() args.Credential.Should().BeSameAs(credential); args.DisplayName.Should().Be("Anonymous"); args.Locator.Should().BeSameAs(locator); - args.Options.Should().NotBeNull(); args.UserId.Should().BeSameAs(userId); } @@ -43,21 +42,6 @@ public void Serialization() new TokenCredential("The token id")) { DisplayName = "The display name", - Options = - { - CallControls = - { - CameraButton = true, - EndCallButton = true, - MicrophoneButton = true, - DevicesButton = true, - MoreButton = true, - ParticipantsButton = true, - PeopleButton = true, - RaiseHandButton = true, - ScreenShareButton = true, - }, - }, }; args.Should().BeJsonSerializableInto(new @@ -75,21 +59,6 @@ public void Serialization() { groupId = "The group id", }, - options = new - { - callControls = new - { - cameraButton = true, - endCallButton = true, - microphoneButton = true, - devicesButton = true, - participantsButton = true, - screenShareButton = true, - moreButton = true, - raiseHandButton = true, - peopleButton = true, - }, - }, }); } @@ -111,21 +80,6 @@ public void Deserialization() { groupId = "The group id", }, - options = new - { - callControls = new - { - cameraButton = true, - endCallButton = true, - microphoneButton = true, - devicesButton = true, - participantsButton = true, - screenShareButton = true, - moreButton = true, - raiseHandButton = true, - peopleButton = true, - }, - }, }; json.Should().BeJsonDeserializableInto(new CallAdapterArgs( @@ -134,21 +88,6 @@ public void Deserialization() new TokenCredential("The token id")) { DisplayName = "The display name", - Options = - { - CallControls = - { - CameraButton = true, - EndCallButton = true, - MicrophoneButton = true, - DevicesButton = true, - MoreButton = true, - ParticipantsButton = true, - PeopleButton = true, - RaiseHandButton = true, - ScreenShareButton = true, - }, - }, }); } } diff --git a/tests/Communication.UI.Blazor.Tests/Calling/CallAdapterTest.cs b/tests/Communication.UI.Blazor.Tests/Calling/CallAdapterTest.cs new file mode 100644 index 0000000..bd8278c --- /dev/null +++ b/tests/Communication.UI.Blazor.Tests/Calling/CallAdapterTest.cs @@ -0,0 +1,357 @@ +//----------------------------------------------------------------------- +// +// Copyright (c) P.O.S Informatique. All rights reserved. +// +//----------------------------------------------------------------------- + +namespace PosInformatique.Azure.Communication.UI.Blazor.Tests +{ + using Microsoft.AspNetCore.Components; + using Microsoft.JSInterop; + + public class CallAdapterTest + { + [Fact] + public void Constructor() + { + var module = Mock.Of(); + + var callAdapter = new CallAdapter(module); + + callAdapter.Id.Should().NotBeEmpty(); + callAdapter.Module.Should().BeSameAs(module); + } + + [Fact] + public async Task DisposeAsync() + { + var elementReference = new ElementReference("The id"); + + var args = new CallAdapterArgs(default, default, default); + + var module = new Mock(MockBehavior.Strict); + module.Setup(m => m.InvokeAsync("dispose", It.IsAny())) + .ReturnsAsync((Microsoft.JSInterop.Infrastructure.IJSVoidResult)null); + + var callAdapter = new CallAdapter(module.Object); + + await callAdapter.DisposeAsync(); + + await callAdapter.Invoking(c => c.JoinCallAsync(default)) + .Should().ThrowExactlyAsync() + .WithMessage("Cannot access a disposed object.\r\nObject name: 'PosInformatique.Azure.Communication.UI.Blazor.CallAdapter'."); + + await callAdapter.Invoking(c => c.LeaveCallAsync(default)) + .Should().ThrowExactlyAsync() + .WithMessage("Cannot access a disposed object.\r\nObject name: 'PosInformatique.Azure.Communication.UI.Blazor.CallAdapter'."); + + await callAdapter.Invoking(c => c.StartScreenShareAsync()) + .Should().ThrowExactlyAsync() + .WithMessage("Cannot access a disposed object.\r\nObject name: 'PosInformatique.Azure.Communication.UI.Blazor.CallAdapter'."); + + await callAdapter.Invoking(c => c.StopScreenShareAsync()) + .Should().ThrowExactlyAsync() + .WithMessage("Cannot access a disposed object.\r\nObject name: 'PosInformatique.Azure.Communication.UI.Blazor.CallAdapter'."); + + await callAdapter.Invoking(c => c.MuteAsync()) + .Should().ThrowExactlyAsync() + .WithMessage("Cannot access a disposed object.\r\nObject name: 'PosInformatique.Azure.Communication.UI.Blazor.CallAdapter'."); + + await callAdapter.Invoking(c => c.UnmuteAsync()) + .Should().ThrowExactlyAsync() + .WithMessage("Cannot access a disposed object.\r\nObject name: 'PosInformatique.Azure.Communication.UI.Blazor.CallAdapter'."); + + module.VerifyAll(); + } + + [Fact] + public async Task InitializeAsync() + { + var args = new CallAdapterArgs(default, default, default); + + object callBackReference = null; + + var module = new Mock(MockBehavior.Strict); + module.Setup(m => m.InvokeAsync("createCallAdapter", It.IsAny())) + .Callback((string _, object[] a) => + { + a.Should().HaveCount(3); + a[0].As().Should().NotBeEmpty(); + a[1].Should().BeSameAs(args); + + callBackReference = a[2].GetPropertyValue("Value"); + }) + .ReturnsAsync((Microsoft.JSInterop.Infrastructure.IJSVoidResult)null); + + var adapter = new CallAdapter(module.Object); + + await adapter.InitializeAsync(args); + + // Check the OnCallEnded event + var endedEvent = new CallEndedEvent(default); + var onCallEndedCalled = false; + + adapter.OnCallEnded += new AsyncEventHandler(e => + { + e.Should().BeSameAs(endedEvent); + onCallEndedCalled = true; + + return Task.CompletedTask; + }); + + callBackReference.Invoke("OnCallEndedAsync", endedEvent); + + onCallEndedCalled.Should().BeTrue(); + + // Check the On, event + var muteEvent = new MicrophoneMuteChangedEvent(default, default); + var onMicrophoneMuteChanged = false; + + adapter.OnMicrophoneMuteChanged += new AsyncEventHandler(e => + { + e.Should().BeSameAs(muteEvent); + onMicrophoneMuteChanged = true; + + return Task.CompletedTask; + }); + + callBackReference.Invoke("OnMicrophoneMuteChangedAsync", muteEvent); + + onMicrophoneMuteChanged.Should().BeTrue(); + + // Check the OnParticipantsJoinedAsync event + var count = 0; + + var joinedParticipant = new[] + { + new RemoteParticipant(default, default), + new RemoteParticipant(default, default), + }; + + adapter.OnParticipantJoined += new AsyncEventHandler(e => + { + e.Participant.Should().BeSameAs(joinedParticipant[count++]); + + return Task.CompletedTask; + }); + + callBackReference.Invoke("OnParticipantsJoinedAsync", [joinedParticipant]); + + count.Should().Be(2); + + // Check the OnParticipantsLeftAsync event + count = 0; + + var removedParticipant = new[] + { + new RemoteParticipant(default, default), + new RemoteParticipant(default, default), + }; + + adapter.OnParticipantLeft += new AsyncEventHandler(e => + { + e.Participant.Should().BeSameAs(removedParticipant[count++]); + + return Task.CompletedTask; + }); + + callBackReference.Invoke("OnParticipantsLeftAsync", [removedParticipant]); + + count.Should().Be(2); + + module.VerifyAll(); + } + + [Fact] + public async Task JoinCallAsync() + { + var options = new JoinCallOptions(); + + var module = new Mock(MockBehavior.Strict); + module.Setup(m => m.InvokeAsync("adapterJoinCall", It.IsAny())) + .Callback((string _, object[] a) => + { + a.Should().HaveCount(2); + a[0].As().Should().NotBeEmpty(); + a[1].Should().BeSameAs(options); + }) + .ReturnsAsync((Microsoft.JSInterop.Infrastructure.IJSVoidResult)null); + + var adapter = new CallAdapter(module.Object); + + await adapter.JoinCallAsync(options); + + module.VerifyAll(); + } + + [Fact] + public async Task JoinAsync_AlreadyDisposed() + { + var adapter = new CallAdapter(default); + + adapter.Dispose(); + + await adapter.Invoking(c => c.JoinCallAsync(default)) + .Should().ThrowExactlyAsync() + .WithMessage("Cannot access a disposed object.\r\nObject name: 'PosInformatique.Azure.Communication.UI.Blazor.CallAdapter'."); + } + + [Fact] + public async Task LeaveCallAsyncAsync() + { + var options = new JoinCallOptions(); + + var module = new Mock(MockBehavior.Strict); + module.Setup(m => m.InvokeAsync("adapterLeaveCall", It.IsAny())) + .Callback((string _, object[] a) => + { + a.Should().HaveCount(2); + a[0].As().Should().NotBeEmpty(); + a[1].Should().Be(true); + }) + .ReturnsAsync((Microsoft.JSInterop.Infrastructure.IJSVoidResult)null); + + var adapter = new CallAdapter(module.Object); + + await adapter.LeaveCallAsync(true); + + module.VerifyAll(); + } + + [Fact] + public async Task LeaveAsync_AlreadyDisposed() + { + var adapter = new CallAdapter(default); + + adapter.Dispose(); + + await adapter.Invoking(c => c.LeaveCallAsync(default)) + .Should().ThrowExactlyAsync() + .WithMessage("Cannot access a disposed object.\r\nObject name: 'PosInformatique.Azure.Communication.UI.Blazor.CallAdapter'."); + } + + [Fact] + public async Task MuteAsync() + { + var module = new Mock(MockBehavior.Strict); + module.Setup(m => m.InvokeAsync("adapterMute", It.IsAny())) + .Callback((string _, object[] a) => + { + a.Should().HaveCount(1); + a[0].As().Should().NotBeEmpty(); + }) + .ReturnsAsync((Microsoft.JSInterop.Infrastructure.IJSVoidResult)null); + + var adapter = new CallAdapter(module.Object); + + await adapter.MuteAsync(); + + module.VerifyAll(); + } + + [Fact] + public async Task MuteAsync_AlreadyDisposed() + { + var adapter = new CallAdapter(default); + + adapter.Dispose(); + + await adapter.Invoking(c => c.MuteAsync()) + .Should().ThrowExactlyAsync() + .WithMessage("Cannot access a disposed object.\r\nObject name: 'PosInformatique.Azure.Communication.UI.Blazor.CallAdapter'."); + } + + [Fact] + public async Task StartScreenShareAsync() + { + var module = new Mock(MockBehavior.Strict); + module.Setup(m => m.InvokeAsync("adapterStartScreenShare", It.IsAny())) + .Callback((string _, object[] a) => + { + a.Should().HaveCount(1); + a[0].As().Should().NotBeEmpty(); + }) + .ReturnsAsync((Microsoft.JSInterop.Infrastructure.IJSVoidResult)null); + + var adapter = new CallAdapter(module.Object); + + await adapter.StartScreenShareAsync(); + + module.VerifyAll(); + } + + [Fact] + public async Task StartScreenShareAsync_AlreadyDisposed() + { + var adapter = new CallAdapter(default); + + adapter.Dispose(); + + await adapter.Invoking(c => c.StartScreenShareAsync()) + .Should().ThrowExactlyAsync() + .WithMessage("Cannot access a disposed object.\r\nObject name: 'PosInformatique.Azure.Communication.UI.Blazor.CallAdapter'."); + } + + [Fact] + public async Task StopScreenShareAsync() + { + var module = new Mock(MockBehavior.Strict); + module.Setup(m => m.InvokeAsync("adapterStopScreenShare", It.IsAny())) + .Callback((string _, object[] a) => + { + a.Should().HaveCount(1); + a[0].As().Should().NotBeEmpty(); + }) + .ReturnsAsync((Microsoft.JSInterop.Infrastructure.IJSVoidResult)null); + + var adapter = new CallAdapter(module.Object); + + await adapter.StopScreenShareAsync(); + + module.VerifyAll(); + } + + [Fact] + public async Task StopScreenShareAsync_AlreadyDisposed() + { + var adapter = new CallAdapter(default); + + adapter.Dispose(); + + await adapter.Invoking(c => c.StopScreenShareAsync()) + .Should().ThrowExactlyAsync() + .WithMessage("Cannot access a disposed object.\r\nObject name: 'PosInformatique.Azure.Communication.UI.Blazor.CallAdapter'."); + } + + [Fact] + public async Task UnmuteAsync() + { + var module = new Mock(MockBehavior.Strict); + module.Setup(m => m.InvokeAsync("adapterUnmute", It.IsAny())) + .Callback((string _, object[] a) => + { + a.Should().HaveCount(1); + a[0].As().Should().NotBeEmpty(); + }) + .ReturnsAsync((Microsoft.JSInterop.Infrastructure.IJSVoidResult)null); + + var adapter = new CallAdapter(module.Object); + + await adapter.UnmuteAsync(); + + module.VerifyAll(); + } + + [Fact] + public async Task UnmuteAsync_AlreadyDisposed() + { + var adapter = new CallAdapter(default); + + adapter.Dispose(); + + await adapter.Invoking(c => c.UnmuteAsync()) + .Should().ThrowExactlyAsync() + .WithMessage("Cannot access a disposed object.\r\nObject name: 'PosInformatique.Azure.Communication.UI.Blazor.CallAdapter'."); + } + } +} \ No newline at end of file diff --git a/tests/Communication.UI.Blazor.Tests/Calling/CallCompositeOptionsTest.cs b/tests/Communication.UI.Blazor.Tests/Calling/CallCompositeOptionsTest.cs deleted file mode 100644 index 5b48910..0000000 --- a/tests/Communication.UI.Blazor.Tests/Calling/CallCompositeOptionsTest.cs +++ /dev/null @@ -1,91 +0,0 @@ -//----------------------------------------------------------------------- -// -// Copyright (c) P.O.S Informatique. All rights reserved. -// -//----------------------------------------------------------------------- - -namespace PosInformatique.Azure.Communication.UI.Blazor.Tests -{ - public class CallCompositeOptionsTest - { - [Fact] - public void Constructor() - { - var options = new CallCompositeOptions(); - - options.CallControls.Should().NotBeNull(); - } - - [Fact] - public void Serialization() - { - var options = new CallCompositeOptions() - { - CallControls = - { - CameraButton = true, - EndCallButton = true, - MicrophoneButton = true, - DevicesButton = true, - MoreButton = true, - ParticipantsButton = true, - PeopleButton = true, - RaiseHandButton = true, - ScreenShareButton = true, - }, - }; - - options.Should().BeJsonSerializableInto(new - { - callControls = new - { - cameraButton = true, - endCallButton = true, - microphoneButton = true, - devicesButton = true, - participantsButton = true, - screenShareButton = true, - moreButton = true, - raiseHandButton = true, - peopleButton = true, - }, - }); - } - - [Fact] - public void Deserialization() - { - var json = new - { - callControls = new - { - cameraButton = true, - endCallButton = true, - microphoneButton = true, - devicesButton = true, - participantsButton = true, - screenShareButton = true, - moreButton = true, - raiseHandButton = true, - peopleButton = true, - }, - }; - - json.Should().BeJsonDeserializableInto(new CallCompositeOptions() - { - CallControls = - { - CameraButton = true, - EndCallButton = true, - MicrophoneButton = true, - DevicesButton = true, - MoreButton = true, - ParticipantsButton = true, - PeopleButton = true, - RaiseHandButton = true, - ScreenShareButton = true, - }, - }); - } - } -} \ No newline at end of file diff --git a/tests/Communication.UI.Blazor.Tests/Calling/CallCompositeTest.cs b/tests/Communication.UI.Blazor.Tests/Calling/CallCompositeTest.cs index 884ee2a..92157e2 100644 --- a/tests/Communication.UI.Blazor.Tests/Calling/CallCompositeTest.cs +++ b/tests/Communication.UI.Blazor.Tests/Calling/CallCompositeTest.cs @@ -6,225 +6,207 @@ namespace PosInformatique.Azure.Communication.UI.Blazor.Tests { + using AngleSharp.Dom; + using Bunit; using Microsoft.AspNetCore.Components; + using Microsoft.Extensions.DependencyInjection; using Microsoft.JSInterop; - public class CallCompositeTest + public class CallCompositeTest : TestContext { [Fact] public void Constructor() { var callComposite = new CallComposite(); - callComposite.IsLoaded.Should().BeFalse(); - callComposite.JSRuntime.Should().BeNull(); - callComposite.OnCallEnded.HasDelegate.Should().BeFalse(); - callComposite.OnParticipantJoined.HasDelegate.Should().BeFalse(); - callComposite.OnParticipantLeft.HasDelegate.Should().BeFalse(); + callComposite.Adapter.Should().BeNull(); + callComposite.CameraButton.Should().BeTrue(); + callComposite.DevicesButton.Should().BeTrue(); + callComposite.EndCallButton.Should().BeTrue(); + callComposite.MicrophoneButton.Should().BeTrue(); + callComposite.MoreButton.Should().BeTrue(); + callComposite.ParticipantsButton.Should().BeTrue(); + callComposite.PeopleButton.Should().BeTrue(); + callComposite.RaiseHandButton.Should().BeTrue(); + callComposite.ScreenShareButton.Should().BeTrue(); } [Fact] - public async Task JoinCallAsync() + public void Adapter_ValueChanged() { - var elementReference = new ElementReference("The id"); - - var options = new JoinCallOptions(); - - var module = new Mock(MockBehavior.Strict); - module.Setup(m => m.InvokeAsync("initialize", It.IsAny())) - .ReturnsAsync((Microsoft.JSInterop.Infrastructure.IJSVoidResult)null); - module.Setup(m => m.InvokeAsync("adapterJoinCall", It.IsAny())) - .Callback((string _, object[] a) => - { - a.Should().HaveCount(2); - a[0].Should().Be(elementReference); - a[1].Should().BeSameAs(options); - }) - .ReturnsAsync((Microsoft.JSInterop.Infrastructure.IJSVoidResult)null); - - var jsRuntime = new Mock(MockBehavior.Strict); - jsRuntime.Setup(j => j.InvokeAsync("import", It.Is(args => (string)args[0] == "./_content/PosInformatique.Azure.Communication.UI.Blazor/Calling/CallComposite.razor.js"))) - .ReturnsAsync(module.Object); - - var callComposite = new CallComposite() - { - JSRuntime = jsRuntime.Object, - }; - - callComposite.SetFieldValue("callContainer", elementReference); - - await callComposite.LoadAsync(default); + var callComposite = new CallComposite(); - await callComposite.JoinCallAsync(options); + var adapter = Mock.Of(); - callComposite.IsLoaded.Should().BeTrue(); + callComposite.Adapter = adapter; - jsRuntime.VerifyAll(); - module.VerifyAll(); + callComposite.Adapter.Should().BeSameAs(adapter); } [Fact] - public async Task JoinCallAsync_NotLoaded() + public void CameraButton_ValueChanged() { - var options = new JoinCallOptions(); - var callComposite = new CallComposite(); - await callComposite.Invoking(c => c.JoinCallAsync(options)) - .Should().ThrowExactlyAsync() - .WithMessage("The component has not been loaded. Ensures that the LoadAsync() method has been called first."); + callComposite.CameraButton = true; + + callComposite.CameraButton.Should().BeTrue(); } [Fact] - public async Task JoinAsync_AlreadyDisposed() + public void DevicesButton_ValueChanged() { var callComposite = new CallComposite(); - callComposite.Dispose(); + callComposite.DevicesButton = true; - await callComposite.Invoking(c => c.JoinCallAsync(default)) - .Should().ThrowExactlyAsync() - .WithMessage("Cannot access a disposed object.\r\nObject name: 'PosInformatique.Azure.Communication.UI.Blazor.CallComposite'."); + callComposite.DevicesButton.Should().BeTrue(); } [Fact] - public async Task LoadAsync() + public void EndCallButton_ValueChanged() { - var elementReference = new ElementReference("The id"); - - var args = new CallAdapterArgs(default, default, default); - - object callBackReference = null; - - var module = new Mock(MockBehavior.Strict); - module.Setup(m => m.InvokeAsync("initialize", It.IsAny())) - .Callback((string _, object[] a) => - { - a.Should().HaveCount(3); - a[0].Should().Be(elementReference); - a[1].Should().BeSameAs(args); - - callBackReference = a[2].GetPropertyValue("Value"); - }) - .ReturnsAsync((Microsoft.JSInterop.Infrastructure.IJSVoidResult)null); - - var jsRuntime = new Mock(MockBehavior.Strict); - jsRuntime.Setup(j => j.InvokeAsync("import", It.Is(args => (string)args[0] == "./_content/PosInformatique.Azure.Communication.UI.Blazor/Calling/CallComposite.razor.js"))) - .ReturnsAsync(module.Object); - - var callComposite = new CallComposite() - { - JSRuntime = jsRuntime.Object, - }; + var callComposite = new CallComposite(); - callComposite.SetFieldValue("callContainer", elementReference); + callComposite.EndCallButton = true; - await callComposite.LoadAsync(args); + callComposite.EndCallButton.Should().BeTrue(); + } - callComposite.IsLoaded.Should().BeTrue(); + [Fact] + public void MicrophoneButton_ValueChanged() + { + var callComposite = new CallComposite(); - // Check the OnCallEnded event - var endedEvent = new CallAdapterCallEndedEvent(default); + callComposite.MicrophoneButton = true; - callComposite.OnCallEnded = new EventCallback(null, (CallAdapterCallEndedEvent e) => - { - e.Should().BeSameAs(endedEvent); - }); + callComposite.MicrophoneButton.Should().BeTrue(); + } - callBackReference.Invoke("OnCallEndedAsync", endedEvent); + [Fact] + public void MoreButton_ValueChanged() + { + var callComposite = new CallComposite(); - // Check the OnParticipantsJoinedAsync event - var count = 0; + callComposite.MoreButton = true; - var joinedParticipant = new[] - { - new RemoteParticipant(default, default), - new RemoteParticipant(default, default), - }; + callComposite.MoreButton.Should().BeTrue(); + } - callComposite.OnParticipantJoined = new EventCallback(null, (RemoteParticipantJoinedEvent e) => - { - e.Participant.Should().BeSameAs(joinedParticipant[count++]); - }); + [Fact] + public void ParticipantsButton_ValueChanged() + { + var callComposite = new CallComposite(); - callBackReference.Invoke("OnParticipantsJoinedAsync", [joinedParticipant]); + callComposite.ParticipantsButton = true; - count.Should().Be(2); + callComposite.ParticipantsButton.Should().BeTrue(); + } - // Check the OnParticipantsLeftAsync event - count = 0; + [Fact] + public void PeopleButton_ValueChanged() + { + var callComposite = new CallComposite(); - var removedParticipant = new[] - { - new RemoteParticipant(default, default), - new RemoteParticipant(default, default), - }; + callComposite.PeopleButton = true; - callComposite.OnParticipantLeft = new EventCallback(null, (RemoteParticipantLeftEvent e) => - { - e.Participant.Should().BeSameAs(removedParticipant[count++]); - }); + callComposite.PeopleButton.Should().BeTrue(); + } - callBackReference.Invoke("OnParticipantsLeftAsync", [removedParticipant]); + [Fact] + public void RaiseHandButton_ValueChanged() + { + var callComposite = new CallComposite(); - count.Should().Be(2); + callComposite.RaiseHandButton = true; - jsRuntime.VerifyAll(); - module.VerifyAll(); + callComposite.RaiseHandButton.Should().BeTrue(); } [Fact] - public async Task LoadAsync_AlreadyDisposed() + public void ScreenShareButton_ValueChanged() { var callComposite = new CallComposite(); - callComposite.Dispose(); + callComposite.ScreenShareButton = true; - await callComposite.Invoking(c => c.LoadAsync(default)) - .Should().ThrowExactlyAsync() - .WithMessage("Cannot access a disposed object.\r\nObject name: 'PosInformatique.Azure.Communication.UI.Blazor.CallComposite'."); + callComposite.ScreenShareButton.Should().BeTrue(); } [Fact] - public async Task DisposeAsync() + public void Render_WithNullAdapter() { - var elementReference = new ElementReference("The id"); + var render = this.RenderComponent(parameters => + { + parameters.Add(p => p.Adapter, null); + }); + + render.MarkupMatches($"
"); + } - var args = new CallAdapterArgs(default, default, default); + [Fact] + public void Render_WithAdapter() + { + ElementReference elementReference = default; + Guid adapterId = default; var module = new Mock(MockBehavior.Strict); - module.Setup(m => m.InvokeAsync("initialize", It.IsAny())) - .ReturnsAsync((Microsoft.JSInterop.Infrastructure.IJSVoidResult)null); - module.Setup(m => m.InvokeAsync("dispose", It.IsAny())) + module.Setup(m => m.InvokeAsync("initializeControl", It.IsAny())) + .Callback((string _, object[] a) => + { + a.Should().HaveCount(3); + + elementReference = a[0].As(); + adapterId = a[1].As(); + + a[2].Should().BeEquivalentTo(new CallControlOptions() + { + CameraButton = true, + DevicesButton = true, + EndCallButton = true, + MicrophoneButton = true, + MoreButton = true, + ParticipantsButton = true, + PeopleButton = true, + RaiseHandButton = true, + ScreenShareButton = true, + }); + }) .ReturnsAsync((Microsoft.JSInterop.Infrastructure.IJSVoidResult)null); - module.Setup(m => m.DisposeAsync()) - .Returns(ValueTask.CompletedTask); - var jsRuntime = new Mock(MockBehavior.Strict); - jsRuntime.Setup(j => j.InvokeAsync("import", It.Is(args => (string)args[0] == "./_content/PosInformatique.Azure.Communication.UI.Blazor/Calling/CallComposite.razor.js"))) - .ReturnsAsync(module.Object); + var adapter = new CallAdapter(module.Object); - var callComposite = new CallComposite() + var render = this.RenderComponent(parameters => { - JSRuntime = jsRuntime.Object, - }; - - callComposite.SetFieldValue("callContainer", elementReference); - - await callComposite.LoadAsync(args); + parameters.Add(p => p.Adapter, adapter); + parameters.Add(p => p.CameraButton, true); + parameters.Add(p => p.DevicesButton, true); + parameters.Add(p => p.EndCallButton, true); + parameters.Add(p => p.MicrophoneButton, true); + parameters.Add(p => p.MoreButton, true); + parameters.Add(p => p.ParticipantsButton, true); + parameters.Add(p => p.PeopleButton, true); + parameters.Add(p => p.RaiseHandButton, true); + parameters.Add(p => p.ScreenShareButton, true); + }); - await callComposite.DisposeAsync(); + adapterId.Should().Be(adapter.Id); + render.Markup.Should().Be($"
"); - await callComposite.Invoking(c => c.JoinCallAsync(default)) - .Should().ThrowExactlyAsync() - .WithMessage("Cannot access a disposed object.\r\nObject name: 'PosInformatique.Azure.Communication.UI.Blazor.CallComposite'."); + module.VerifyAll(); + } - await callComposite.Invoking(c => c.LoadAsync(default)) - .Should().ThrowExactlyAsync() - .WithMessage("Cannot access a disposed object.\r\nObject name: 'PosInformatique.Azure.Communication.UI.Blazor.CallComposite'."); + [Fact] + public void Render_WithNoCallAdapter() + { + var adapter = Mock.Of(); - jsRuntime.VerifyAll(); - module.VerifyAll(); + this.Invoking(t => t.RenderComponent(parameters => + { + parameters.Add(p => p.Adapter, adapter); + })) + .Should().ThrowExactly() + .WithMessage("The Adapter property must an instance of the CallAdapter class."); } } } \ No newline at end of file diff --git a/tests/Communication.UI.Blazor.Tests/Calling/CallingServiceCollectionExtensionsTest.cs b/tests/Communication.UI.Blazor.Tests/Calling/CallingServiceCollectionExtensionsTest.cs new file mode 100644 index 0000000..cfc741a --- /dev/null +++ b/tests/Communication.UI.Blazor.Tests/Calling/CallingServiceCollectionExtensionsTest.cs @@ -0,0 +1,28 @@ +//----------------------------------------------------------------------- +// +// Copyright (c) P.O.S Informatique. All rights reserved. +// +//----------------------------------------------------------------------- + +namespace PosInformatique.Azure.Communication.UI.Blazor.Tests +{ + using Microsoft.Extensions.DependencyInjection; + using Microsoft.JSInterop; + + public class CallingServiceCollectionExtensionsTest + { + [Fact] + public void AddCalling() + { + var serviceCollection = new ServiceCollection(); + + serviceCollection.AddSingleton(sp => Mock.Of()); + serviceCollection.AddCalling().Should().BeSameAs(serviceCollection); + + var sp = serviceCollection.BuildServiceProvider(); + + sp.GetRequiredService().Should().NotBeNull(); + sp.GetRequiredService().Should().BeSameAs(sp.GetRequiredService()); + } + } +} \ No newline at end of file diff --git a/tests/Communication.UI.Blazor.Tests/Calling/CallingServiceTest.cs b/tests/Communication.UI.Blazor.Tests/Calling/CallingServiceTest.cs new file mode 100644 index 0000000..7af87ee --- /dev/null +++ b/tests/Communication.UI.Blazor.Tests/Calling/CallingServiceTest.cs @@ -0,0 +1,75 @@ +//----------------------------------------------------------------------- +// +// Copyright (c) P.O.S Informatique. All rights reserved. +// +//----------------------------------------------------------------------- + +namespace PosInformatique.Azure.Communication.UI.Blazor.Tests +{ + using Microsoft.JSInterop; + + public class CallingServiceTest + { + [Fact] + public async Task CreateAdapterAsync() + { + var args = new CallAdapterArgs(default, default, default); + + var module = new Mock(MockBehavior.Strict); + module.Setup(m => m.InvokeAsync("createCallAdapter", It.IsAny())) + .Callback((string _, object[] a) => + { + a.Should().HaveCount(3); + a[0].As().Should().NotBeEmpty(); + a[1].Should().BeSameAs(args); + a[1].Should().NotBeNull(); + }) + .ReturnsAsync((Microsoft.JSInterop.Infrastructure.IJSVoidResult)null); + + var jsRuntime = new Mock(MockBehavior.Strict); + jsRuntime.Setup(j => j.InvokeAsync("import", It.Is(args => (string)args[0] == "./_content/PosInformatique.Azure.Communication.UI.Blazor/Calling/CallComposite.razor.js"))) + .ReturnsAsync(module.Object); + + var callingService = new CallingService(jsRuntime.Object); + + var adapter = await callingService.CreateAdapterAsync(args); + + adapter.Should().NotBeNull(); + + jsRuntime.VerifyAll(); + module.VerifyAll(); + } + + [Fact] + public async Task DisposeAsync() + { + var args = new CallAdapterArgs(default, default, default); + + var module = new Mock(MockBehavior.Strict); + module.Setup(m => m.DisposeAsync()) + .Returns(ValueTask.CompletedTask); + module.Setup(m => m.InvokeAsync("createCallAdapter", It.IsAny())) + .Callback((string _, object[] a) => + { + a.Should().HaveCount(3); + a[0].As().Should().NotBeEmpty(); + a[1].Should().BeSameAs(args); + a[1].Should().NotBeNull(); + }) + .ReturnsAsync((Microsoft.JSInterop.Infrastructure.IJSVoidResult)null); + + var jsRuntime = new Mock(MockBehavior.Strict); + jsRuntime.Setup(j => j.InvokeAsync("import", It.Is(args => (string)args[0] == "./_content/PosInformatique.Azure.Communication.UI.Blazor/Calling/CallComposite.razor.js"))) + .ReturnsAsync(module.Object); + + var callingService = new CallingService(jsRuntime.Object); + + await callingService.CreateAdapterAsync(args); + + await callingService.DisposeAsync(); + + jsRuntime.VerifyAll(); + module.VerifyAll(); + } + } +} \ No newline at end of file diff --git a/tests/Communication.UI.Blazor.Tests/Calling/CallAdapterCallEndedEventTest.cs b/tests/Communication.UI.Blazor.Tests/Calling/Events/CallEndedEventTest.cs similarity index 68% rename from tests/Communication.UI.Blazor.Tests/Calling/CallAdapterCallEndedEventTest.cs rename to tests/Communication.UI.Blazor.Tests/Calling/Events/CallEndedEventTest.cs index 1d4c7b7..a22ecf3 100644 --- a/tests/Communication.UI.Blazor.Tests/Calling/CallAdapterCallEndedEventTest.cs +++ b/tests/Communication.UI.Blazor.Tests/Calling/Events/CallEndedEventTest.cs @@ -1,17 +1,17 @@ //----------------------------------------------------------------------- -// +// // Copyright (c) P.O.S Informatique. All rights reserved. // //----------------------------------------------------------------------- namespace PosInformatique.Azure.Communication.UI.Blazor.Tests { - public class CallAdapterCallEndedEventTest + public class CallEndedEventTest { [Fact] public void Constructor() { - var @event = new CallAdapterCallEndedEvent("The id"); + var @event = new CallEndedEvent("The id"); @event.CallId.Should().Be("The id"); } @@ -19,7 +19,7 @@ public void Constructor() [Fact] public void Serialization() { - var @event = new CallAdapterCallEndedEvent("The id"); + var @event = new CallEndedEvent("The id"); @event.Should().BeJsonSerializableInto(new { @@ -35,7 +35,7 @@ public void Deserialization() callId = "The id", }; - json.Should().BeJsonDeserializableInto(new CallAdapterCallEndedEvent("The id")); + json.Should().BeJsonDeserializableInto(new CallEndedEvent("The id")); } } } \ No newline at end of file diff --git a/tests/Communication.UI.Blazor.Tests/Calling/Events/MicrophoneMuteChangedEventTest.cs b/tests/Communication.UI.Blazor.Tests/Calling/Events/MicrophoneMuteChangedEventTest.cs new file mode 100644 index 0000000..bfc1623 --- /dev/null +++ b/tests/Communication.UI.Blazor.Tests/Calling/Events/MicrophoneMuteChangedEventTest.cs @@ -0,0 +1,21 @@ +//----------------------------------------------------------------------- +// +// Copyright (c) P.O.S Informatique. All rights reserved. +// +//----------------------------------------------------------------------- + +namespace PosInformatique.Azure.Communication.UI.Blazor.Tests +{ + public class MicrophoneMuteChangedEventTest + { + [Fact] + public void Constructor() + { + var participantId = new CommunicationUserKind(default); + var @event = new MicrophoneMuteChangedEvent(participantId, true); + + @event.IsMuted.Should().BeTrue(); + @event.ParticipantId.Should().BeSameAs(participantId); + } + } +} \ No newline at end of file diff --git a/tests/Communication.UI.Blazor.Tests/Calling/RemoteParticipantJoinedEventTest.cs b/tests/Communication.UI.Blazor.Tests/Calling/Events/RemoteParticipantJoinedEventTest.cs similarity index 100% rename from tests/Communication.UI.Blazor.Tests/Calling/RemoteParticipantJoinedEventTest.cs rename to tests/Communication.UI.Blazor.Tests/Calling/Events/RemoteParticipantJoinedEventTest.cs diff --git a/tests/Communication.UI.Blazor.Tests/Calling/RemoteParticipantLeftEventTest.cs b/tests/Communication.UI.Blazor.Tests/Calling/Events/RemoteParticipantLeftEventTest.cs similarity index 100% rename from tests/Communication.UI.Blazor.Tests/Calling/RemoteParticipantLeftEventTest.cs rename to tests/Communication.UI.Blazor.Tests/Calling/Events/RemoteParticipantLeftEventTest.cs diff --git a/tests/Communication.UI.Blazor.Tests/Communication.UI.Blazor.Tests.csproj b/tests/Communication.UI.Blazor.Tests/Communication.UI.Blazor.Tests.csproj index 23ced03..8ea7c72 100644 --- a/tests/Communication.UI.Blazor.Tests/Communication.UI.Blazor.Tests.csproj +++ b/tests/Communication.UI.Blazor.Tests/Communication.UI.Blazor.Tests.csproj @@ -7,6 +7,7 @@ + all runtime; build; native; contentfiles; analyzers; buildtransitive