File: Components\Interactions\InteractionsProvider.cs
Web Access
Project: src\src\Aspire.Dashboard\Aspire.Dashboard.csproj (Aspire.Dashboard)
// Licensed to the .NET Foundation under one or more agreements.
// The .NET Foundation licenses this file to you under the MIT license.
 
using System.Collections.ObjectModel;
using System.Diagnostics;
using System.Net;
using Aspire.Dashboard.Components.Dialogs;
using Aspire.Dashboard.Model;
using Aspire.Dashboard.Utils;
using Aspire.ResourceService.Proto.V1;
using Microsoft.AspNetCore.Components;
using Microsoft.Extensions.Localization;
using Microsoft.FluentUI.AspNetCore.Components;
using Color = Microsoft.FluentUI.AspNetCore.Components.Color;
using MessageIntentDto = Aspire.ResourceService.Proto.V1.MessageIntent;
using MessageIntentUI = Microsoft.FluentUI.AspNetCore.Components.MessageIntent;
 
namespace Aspire.Dashboard.Components.Interactions;
 
public class InteractionsProvider : ComponentBase, IAsyncDisposable
{
    private record InteractionMessageBarReference(WatchInteractionsResponseUpdate Interaction, Message Message);
    private record InteractionDialogReference(WatchInteractionsResponseUpdate Interaction, IDialogReference Dialog);
 
    private readonly CancellationTokenSource _cts = new();
    private readonly SemaphoreSlim _semaphore = new SemaphoreSlim(1, 1);
    private readonly KeyedInteractionCollection _pendingInteractions = new();
    private readonly List<InteractionMessageBarReference> _openMessageBars = new();
 
    private Task? _interactionsDisplayTask;
    private Task? _watchInteractionsTask;
    private TaskCompletionSource _interactionAvailableTcs = new(TaskCreationOptions.RunContinuationsAsynchronously);
    private InteractionDialogReference? _interactionDialogReference;
 
    [Inject]
    public required IDashboardClient DashboardClient { get; init; }
 
    [Inject]
    public required IDialogService DialogService { get; init; }
 
    [Inject]
    public required IMessageService MessageService { get; init; }
 
    [Inject]
    public required IStringLocalizer<Resources.Dialogs> Loc { get; init; }
 
    [Inject]
    public required ILogger<InteractionsProvider> Logger { get; init; }
 
    protected override void OnInitialized()
    {
        // Exit quickly if the dashboard client is not enabled. For example, the dashboard is running in the standalone container.
        if (!DashboardClient.IsEnabled)
        {
            return;
        }
 
        _interactionsDisplayTask = Task.Run(async () =>
        {
            var waitForInteractionAvailableTask = Task.CompletedTask;
 
            while (!_cts.IsCancellationRequested)
            {
                // If there are no pending interactions then wait on this task to get notified when one is added.
                await waitForInteractionAvailableTask.WaitAsync(_cts.Token).ConfigureAwait(false);
 
                IDialogReference? currentDialogReference = null;
 
                await _semaphore.WaitAsync(_cts.Token).ConfigureAwait(false);
                try
                {
                    if (_pendingInteractions.Count == 0)
                    {
                        // Task is set when a new interaction is added.
                        // Continue here will exit the async lock and wait for the task to complete.
                        waitForInteractionAvailableTask = _interactionAvailableTcs.Task;
                        continue;
                    }
 
                    waitForInteractionAvailableTask = Task.CompletedTask;
                    var item = ((IList<WatchInteractionsResponseUpdate>)_pendingInteractions)[0];
                    _pendingInteractions.RemoveAt(0);
 
                    Func<IDialogService, Task<IDialogReference>> openDialog;
 
                    if (item.MessageBox is { } messageBox)
                    {
                        var dialogParameters = CreateDialogParameters(item, messageBox.Intent);
                        dialogParameters.OnDialogResult = EventCallback.Factory.Create<DialogResult>(this, async dialogResult =>
                        {
                            var request = new WatchInteractionsRequestUpdate
                            {
                                InteractionId = item.InteractionId
                            };
 
                            if (dialogResult.Cancelled)
                            {
                                // There will be data in the dialog result on cancel if the secondary button is clicked.
                                if (dialogResult.Data != null)
                                {
                                    messageBox.Result = false;
                                    request.MessageBox = messageBox;
                                }
                                else
                                {
                                    request.Complete = new InteractionComplete();
                                }
                            }
                            else
                            {
                                messageBox.Result = true;
                                request.MessageBox = messageBox;
                            }
 
                            await DashboardClient.SendInteractionRequestAsync(request, _cts.Token).ConfigureAwait(false);
                        });
 
                        var content = new MessageBoxContent
                        {
                            Title = item.Title,
                            MarkupMessage = new MarkupString(item.Message),
                        };
                        switch (messageBox.Intent)
                        {
                            case MessageIntentDto.Success:
                                content.IconColor = Color.Success;
                                content.Icon = new Microsoft.FluentUI.AspNetCore.Components.Icons.Filled.Size24.CheckmarkCircle();
                                break;
                            case MessageIntentDto.Warning:
                                content.IconColor = Color.Warning;
                                content.Icon = new Microsoft.FluentUI.AspNetCore.Components.Icons.Filled.Size24.Warning();
                                break;
                            case MessageIntentDto.Error:
                                content.IconColor = Color.Error;
                                content.Icon = new Microsoft.FluentUI.AspNetCore.Components.Icons.Filled.Size24.DismissCircle();
                                break;
                            case MessageIntentDto.Information:
                                content.IconColor = Color.Info;
                                content.Icon = new Microsoft.FluentUI.AspNetCore.Components.Icons.Filled.Size24.Info();
                                break;
                            case MessageIntentDto.Confirmation:
                                content.IconColor = Color.Success;
                                content.Icon = new Microsoft.FluentUI.AspNetCore.Components.Icons.Filled.Size24.QuestionCircle();
                                break;
                        }
 
                        openDialog = dialogService => ShowMessageBoxAsync(dialogService, content, dialogParameters);
                    }
                    else if (item.InputsDialog is { } inputs)
                    {
                        var vm = new InteractionsInputsDialogViewModel
                        {
                            Interaction = item,
                            Inputs = inputs.InputItems.ToList()
                        };
 
                        var dialogParameters = CreateDialogParameters(item, intent: null);
                        dialogParameters.OnDialogResult = EventCallback.Factory.Create<DialogResult>(this, async dialogResult =>
                        {
                            var request = new WatchInteractionsRequestUpdate
                            {
                                InteractionId = item.InteractionId
                            };
 
                            if (dialogResult.Cancelled)
                            {
                                request.Complete = new InteractionComplete();
                            }
                            else
                            {
                                request.InputsDialog = item.InputsDialog;
                            }
 
                            await DashboardClient.SendInteractionRequestAsync(request, _cts.Token).ConfigureAwait(false);
                        });
 
                        openDialog = dialogService => dialogService.ShowDialogAsync<InteractionsInputDialog>(vm, dialogParameters);
                    }
                    else
                    {
                        Logger.LogWarning("Unexpected interaction kind: {Kind}", item.KindCase);
                        continue;
                    }
 
                    await InvokeAsync(async () =>
                    {
                        currentDialogReference = await openDialog(DialogService);
                    });
 
                    Debug.Assert(currentDialogReference != null, "Dialog should have been created in UI thread.");
                    _interactionDialogReference = new InteractionDialogReference(item, currentDialogReference);
                }
                finally
                {
                    _semaphore.Release();
                }
 
                try
                {
                    if (currentDialogReference != null)
                    {
                        await currentDialogReference.Result.WaitAsync(_cts.Token);
                    }
                }
                catch
                {
                    // Ignore any exceptions that occur while waiting for the dialog to close.
                }
            }
        });
 
        _watchInteractionsTask = Task.Run(async () =>
        {
            var interactions = DashboardClient.SubscribeInteractionsAsync(_cts.Token);
            await foreach (var item in interactions)
            {
                await _semaphore.WaitAsync(_cts.Token).ConfigureAwait(false);
                try
                {
                    switch (item.KindCase)
                    {
                        case WatchInteractionsResponseUpdate.KindOneofCase.MessageBox:
                        case WatchInteractionsResponseUpdate.KindOneofCase.InputsDialog:
                            // New or updated interaction.
                            _pendingInteractions.Remove(item.InteractionId);
                            _pendingInteractions.Add(item);
 
                            NotifyInteractionAvailable();
                            break;
                        case WatchInteractionsResponseUpdate.KindOneofCase.MessageBar:
                            var messageBar = item.MessageBar;
 
                            Message? message = null;
                            await InvokeAsync(async () =>
                            {
                                message = await MessageService.ShowMessageBarAsync(options =>
                                {
                                    options.Title = WebUtility.HtmlEncode(item.Title);
                                    options.Body = item.Message; // Message is already HTML encoded depending on options.
                                    options.Intent = MapMessageIntent(messageBar.Intent);
                                    options.Section = DashboardUIHelpers.MessageBarSection;
                                    options.AllowDismiss = item.ShowDismiss;
 
                                    var primaryButtonText = item.PrimaryButtonText;
                                    var secondaryButtonText = item.SecondaryButtonText;
                                    if (messageBar.Intent == MessageIntentDto.Confirmation)
                                    {
                                        primaryButtonText = string.IsNullOrEmpty(primaryButtonText) ? "OK" : primaryButtonText;
                                        secondaryButtonText = string.IsNullOrEmpty(secondaryButtonText) ? "Cancel" : secondaryButtonText;
                                    }
 
                                    bool? result = null;
 
                                    if (!string.IsNullOrEmpty(primaryButtonText))
                                    {
                                        options.PrimaryAction = new ActionButton<Message>
                                        {
                                            Text = primaryButtonText,
                                            OnClick = m =>
                                            {
                                                result = true;
                                                m.Close();
                                                return Task.CompletedTask;
                                            }
                                        };
                                    }
                                    if (item.ShowSecondaryButton && !string.IsNullOrEmpty(secondaryButtonText))
                                    {
                                        options.SecondaryAction = new ActionButton<Message>
                                        {
                                            Text = secondaryButtonText,
                                            OnClick = m =>
                                            {
                                                result = false;
                                                m.Close();
                                                return Task.CompletedTask;
                                            }
                                        };
                                    }
 
                                    options.OnClose = async m =>
                                    {
                                        // Only send complete notification if in the open message bars list.
                                        var openMessageBar = _openMessageBars.SingleOrDefault(r => r.Interaction.InteractionId == item.InteractionId);
                                        if (openMessageBar != null)
                                        {
                                            var request = new WatchInteractionsRequestUpdate
                                            {
                                                InteractionId = item.InteractionId
                                            };
 
                                            if (result == null)
                                            {
                                                request.Complete = new InteractionComplete();
                                            }
                                            else
                                            {
                                                messageBar.Result = result.Value;
                                                request.MessageBar = messageBar;
                                            }
 
                                            _openMessageBars.Remove(openMessageBar);
 
                                            await DashboardClient.SendInteractionRequestAsync(request, _cts.Token).ConfigureAwait(false);
                                        }
                                    };
                                });
                            });
 
                            Debug.Assert(message != null, "Message should have been created in UI thread.");
                            _openMessageBars.Add(new InteractionMessageBarReference(item, message));
                            break;
                        case WatchInteractionsResponseUpdate.KindOneofCase.Complete:
                            // Complete interaction.
                            _pendingInteractions.Remove(item.InteractionId);
 
                            // Close the interaction's dialog if it is open.
                            if (_interactionDialogReference?.Interaction.InteractionId == item.InteractionId)
                            {
                                try
                                {
                                    await InvokeAsync(async () =>
                                    {
                                        await _interactionDialogReference.Dialog.CloseAsync();
                                    });
                                }
                                catch (Exception ex)
                                {
                                    Logger.LogDebug(ex, "Unexpected error when closing interaction {InteractionId} dialog reference.", item.InteractionId);
                                }
                                finally
                                {
                                    _interactionDialogReference = null;
                                }
                            }
 
                            var openMessageBar = _openMessageBars.SingleOrDefault(r => r.Interaction.InteractionId == item.InteractionId);
                            if (openMessageBar != null)
                            {
                                // Open message bars is used to decide whether to report completion to the server.
                                // It's already complete so remove before close.
                                _openMessageBars.Remove(openMessageBar);
 
                                // InvokeAsync not necessary here. It is called internally.
                                openMessageBar.Message.Close();
                            }
                            break;
                        default:
                            Logger.LogWarning("Unexpected interaction kind: {Kind}", item.KindCase);
                            break;
                    }
                }
                finally
                {
                    _semaphore.Release();
                }
            }
        });
    }
 
    private static MessageIntentUI MapMessageIntent(MessageIntentDto intent)
    {
        switch (intent)
        {
            case MessageIntentDto.Success:
                return MessageIntentUI.Success;
            case MessageIntentDto.Warning:
                return MessageIntentUI.Warning;
            case MessageIntentDto.Error:
                return MessageIntentUI.Error;
            case MessageIntentDto.Information:
                return MessageIntentUI.Info;
            default:
                return MessageIntentUI.Info;
        }
    }
 
    private DialogParameters CreateDialogParameters(WatchInteractionsResponseUpdate interaction, MessageIntentDto? intent)
    {
        var dialogParameters = new DialogParameters
        {
            ShowDismiss = interaction.ShowDismiss,
            DismissTitle = Loc[nameof(Resources.Dialogs.DialogCloseButtonText)],
            PrimaryAction = ResolvedPrimaryButtonText(interaction, intent),
            SecondaryAction = ResolvedSecondaryButtonText(interaction),
            PreventDismissOnOverlayClick = true,
            Title = interaction.Title
        };
 
        return dialogParameters;
    }
 
    private string ResolvedPrimaryButtonText(WatchInteractionsResponseUpdate interaction, MessageIntentDto? intent)
    {
        if (interaction.PrimaryButtonText is { Length: > 0 } primaryText)
        {
            return primaryText;
        }
        if (intent == MessageIntentDto.Error)
        {
            return Loc[nameof(Resources.Dialogs.InteractionButtonClose)];
        }
 
        return Loc[nameof(Resources.Dialogs.InteractionButtonOk)];
    }
 
    private string ResolvedSecondaryButtonText(WatchInteractionsResponseUpdate interaction)
    {
        if (!interaction.ShowSecondaryButton)
        {
            return string.Empty;
        }
 
        return interaction.SecondaryButtonText is { Length: > 0 } secondaryText
            ? secondaryText
            : Loc[nameof(Resources.Dialogs.InteractionButtonCancel)];
    }
 
    public async Task<IDialogReference> ShowMessageBoxAsync(IDialogService dialogService, MessageBoxContent content, DialogParameters parameters)
    {
        var dialogParameters = new DialogParameters
        {
            DialogType = DialogType.MessageBox,
            Alignment = HorizontalAlignment.Center,
            Title = content.Title,
            ShowDismiss = false,
            PrimaryAction = parameters.PrimaryAction,
            SecondaryAction = parameters.SecondaryAction,
            Width = parameters.Width,
            Height = parameters.Height,
            AriaLabel = (content.Title ?? ""),
            OnDialogResult = parameters.OnDialogResult
        };
        return await dialogService.ShowDialogAsync(typeof(MessageBox), content, dialogParameters);
    }
 
    private void NotifyInteractionAvailable()
    {
        // Let current waiters know that an interaction is available.
        _interactionAvailableTcs.TrySetResult();
 
        // Reset the task completion source for future waiters.
        _interactionAvailableTcs = new TaskCompletionSource(TaskCreationOptions.RunContinuationsAsynchronously);
    }
 
    public async ValueTask DisposeAsync()
    {
        _cts.Cancel();
 
        await TaskHelpers.WaitIgnoreCancelAsync(_interactionsDisplayTask);
        await TaskHelpers.WaitIgnoreCancelAsync(_watchInteractionsTask);
    }
 
    private class KeyedInteractionCollection : KeyedCollection<int, WatchInteractionsResponseUpdate>
    {
        protected override int GetKeyForItem(WatchInteractionsResponseUpdate item)
        {
            return item.InteractionId;
        }
    }
}