Rewrite the Notification dropdown with Blazor (#5325)

* Rewrite the Notification dropdown with Blazor

* Test fix

---------

Co-authored-by: Dennis Reimann <mail@dennisreimann.de>
This commit is contained in:
Nicolas Dorier 2023-09-18 10:55:05 +09:00 committed by GitHub
parent e694568674
commit 44df8cf0c5
No known key found for this signature in database
GPG Key ID: 4AEE18F83AFDEB23
15 changed files with 223 additions and 265 deletions

View File

@ -1862,7 +1862,7 @@ namespace BTCPayServer.Tests
s.GoToHome();
//offline/external payout test
s.Driver.FindElement(By.Id("NotificationsHandle")).Click();
s.Driver.FindElement(By.CssSelector("#notificationsForm button")).Click();
s.Driver.FindElement(By.Id("NotificationsMarkAllAsSeen")).Click();
var newStore = s.CreateNewStore();
s.GenerateWallet("BTC", "", true, true);

View File

@ -0,0 +1,14 @@
using Microsoft.CodeAnalysis.CSharp.Syntax;
using Microsoft.JSInterop;
namespace BTCPayServer.Blazor
{
public static class BlazorExtensions
{
public static bool IsPreRendering(this IJSRuntime runtime)
{
// The peculiar thing in prerender is that Blazor circuit isn't yet created, so we can't use JSInterop
return !(bool)runtime.GetType().GetProperty("IsInitialized").GetValue(runtime);
}
}
}

View File

@ -0,0 +1,22 @@
@using BTCPayServer.Abstractions.Extensions;
@using BTCPayServer.Configuration;
@using Microsoft.AspNetCore.Hosting;
@using Microsoft.AspNetCore.Mvc.Routing;
@using Microsoft.AspNetCore.Mvc.ViewFeatures;
@using Microsoft.AspNetCore.Mvc;
@inject IFileVersionProvider FileVersionProvider
@inject BTCPayServerOptions BTCPayServerOptions
<svg role="img" class="icon icon-@Symbol">
<use href="@GetPathTo(Symbol)"></use>
</svg>
@code {
public string GetPathTo(string symbol)
{
var versioned = FileVersionProvider.AddFileVersionToPath(default, "img/icon-sprite.svg");
var rootPath = (BTCPayServerOptions.RootPath ?? "/").WithTrailingSlash();
return $"{rootPath}{versioned}#{Symbol}";
}
[Parameter]
public string Symbol { get; set; }
}

View File

@ -0,0 +1,152 @@
@using System.Security.Claims
@using BTCPayServer.Abstractions.Contracts;
@using BTCPayServer.Configuration;
@using BTCPayServer.Data;
@using BTCPayServer.Services.Notifications;
@using Microsoft.AspNetCore.Identity;
@using Microsoft.AspNetCore.Routing;
@implements IDisposable
@inject AuthenticationStateProvider _AuthenticationStateProvider
@inject NotificationManager _NotificationManager
@inject UserManager<ApplicationUser> _UserManager
@inject IJSRuntime _JSRuntime
@inject LinkGenerator _LinkGenerator
@inject BTCPayServerOptions _BTCPayServerOptions
@inject EventAggregator _EventAggregator
<div id="Notifications">
@if (UnseenCount == "0")
{
<a href="@NotificationsUrl" id="NotificationsHandle" class="mainMenuButton" title="Notifications">
<Icon Symbol="notifications" />
</a>
}
else
{
<button id="NotificationsHandle" class="mainMenuButton" title="Notifications" type="button" data-bs-toggle="dropdown">
<Icon Symbol="notifications" />
<span class="badge rounded-pill bg-danger p-1 ms-1" id="NotificationsBadge">@UnseenCount</span>
</button>
}
@if (UnseenCount != "0" && Last5 is not null)
{
<div class="dropdown-menu text-center" id="NotificationsDropdown" aria-labelledby="NotificationsHandle">
<div class="d-flex gap-3 align-items-center justify-content-between py-3 px-4 border-bottom border-light">
<h5 class="m-0">Notifications</h5>
<a class="btn btn-link p-0" @onclick="MarkAllAsSeen" id="NotificationsMarkAllAsSeen">Mark all as seen</a>
</div>
<div id="NotificationsList" v-pre>
@foreach (var n in Last5)
{
<a href="@NotificationUrl(n.Id)" class="notification d-flex align-items-center dropdown-item border-bottom border-light py-3 px-4">
<div class="me-3">
<Icon Symbol="@NotificationIcon(n.Identifier)" />
</div>
<div class="notification-item__content">
<div class="text-start text-wrap">
@n.Body
</div>
<div class="text-start d-flex">
<small class="text-muted" data-timeago-unixms="@n.Created.ToUnixTimeMilliseconds()">@n.Created.ToTimeAgo()</small>
</div>
</div>
</a>
}
</div>
<div class="p-3">
<a href="@NotificationsUrl">View all</a>
</div>
</div>
}
</div>
@code {
string NotificationsUrl => _LinkGenerator.GetPathByAction("Index", "UINotifications", pathBase: _BTCPayServerOptions.RootPath);
string NotificationUrl(string notificationId) => _LinkGenerator.GetPathByAction("NotificationPassThrough", "UINotifications", values: new { id = notificationId }, pathBase: _BTCPayServerOptions.RootPath);
string UnseenCount;
List<NotificationViewModel> Last5;
IDisposable _EventAggregatorListener;
protected override void OnInitialized()
{
if (_JSRuntime.IsPreRendering())
return;
_EventAggregatorListener = _EventAggregator.Subscribe<UserNotificationsUpdatedEvent>((s, evt) =>
{
_ = InvokeAsync(async () =>
{
if (await GetUserId() is string userId)
{
var res = await _NotificationManager.GetSummaryNotifications(userId, cachedOnly: false);
UpdateState(res);
StateHasChanged();
}
});
});
}
public void Dispose() => _EventAggregatorListener?.Dispose();
string SeenCount(int? count)
{
if (count is not int c)
return "0";
if (c >= NotificationManager.MaxUnseen)
return $"{NotificationManager.MaxUnseen - 1}+";
return c.ToString();
}
void UpdateState((List<NotificationViewModel> Items, int? Count) res)
{
UnseenCount = SeenCount(res.Count);
Last5 = res.Items;
}
protected async override Task OnParametersSetAsync()
{
if (await GetUserId() is string userId)
{
// For prerendering and first rendering, always use the cached value
var res = await _NotificationManager.GetSummaryNotifications(userId, cachedOnly: true);
// If we forget to update the state here, the UI will flicker.
// Because the first rendering will think there is 0 events, until the DB call ends and the second rendering happens.
// By updating the state here, the first rendering will show the cached value until the second rendering happens
UpdateState(res);
// We don't want to block the pre-rendering, so we will render again when the costly request is over
if (!_JSRuntime.IsPreRendering())
{
res = await _NotificationManager.GetSummaryNotifications(userId, cachedOnly: false);
UpdateState(res);
}
}
}
async Task<string>
GetUserId()
{
var state = await _AuthenticationStateProvider.GetAuthenticationStateAsync();
if (!state.User.Identity.IsAuthenticated)
return null;
return _UserManager.GetUserId(state.User);
}
public async Task MarkAllAsSeen()
{
if (await GetUserId() is string userId)
{
await _NotificationManager.ToggleSeen(new NotificationsQuery() { Seen = false, UserId = userId }, true);
UnseenCount = "0";
}
}
private static string NotificationIcon(string type)
{
return type switch
{
"invoice_expired" => "notifications-invoice-failure",
"invoice_expiredpaidpartial" => "notifications-invoice-failure",
"invoice_failedtoconfirm" => "notifications-invoice-failure",
"invoice_confirmed" => "notifications-invoice-settled",
"invoice_paidafterexpiration" => "notifications-invoice-settled",
"external-payout-transaction" => "notifications-payout",
"payout_awaitingapproval" => "notifications-payout",
"payout_awaitingpayment" => "notifications-payout-approved",
"newversion" => "notifications-new-version",
_ => "note"
};
}
}

View File

@ -5,3 +5,5 @@
@using Microsoft.AspNetCore.Components.Routing
@using Microsoft.AspNetCore.Components.Web
@using Microsoft.JSInterop
@using BTCPayServer.Blazor
@using BTCPayServer.Abstractions.Extensions

View File

@ -1,31 +0,0 @@
@using BTCPayServer.Views.Notifications
@using BTCPayServer.Abstractions.Extensions
@model BTCPayServer.Components.Notifications.NotificationsViewModel
<div id="Notifications">
@if (Model.UnseenCount > 0)
{
<button id="NotificationsHandle" class="mainMenuButton @ViewData.IsActiveCategory(typeof(NotificationsNavPages))" title="Notifications" type="button" data-bs-toggle="dropdown">
<vc:icon symbol="notifications" />
<span class="badge rounded-pill bg-danger p-1 ms-1" id="NotificationsBadge">@Model.UnseenCount</span>
</button>
<div class="dropdown-menu text-center" id="NotificationsDropdown" aria-labelledby="NotificationsHandle">
<div class="d-flex gap-3 align-items-center justify-content-between py-3 px-4 border-bottom border-light">
<h5 class="m-0">Notifications</h5>
<form id="notificationsForm" asp-controller="UINotifications" asp-action="MarkAllAsSeen" asp-route-returnUrl="@Model.ReturnUrl" method="post">
<button class="btn btn-link p-0" type="submit">Mark all as seen</button>
</form>
</div>
<partial name="Components/Notifications/List" model="Model"/>
<div class="p-3">
<a asp-controller="UINotifications" asp-action="Index">View all</a>
</div>
</div>
}
else
{
<a asp-controller="UINotifications" asp-action="Index" id="NotificationsHandle" class="mainMenuButton @ViewData.IsActiveCategory(typeof(NotificationsNavPages))" title="Notifications">
<vc:icon symbol="notifications" />
</a>
}
</div>

View File

@ -1,38 +0,0 @@
@using BTCPayServer.Abstractions.Extensions
@model BTCPayServer.Components.Notifications.NotificationsViewModel
@functions {
private static string NotificationIcon(string type)
{
return type switch
{
"invoice_expired" => "notifications-invoice-failure",
"invoice_expiredpaidpartial" => "notifications-invoice-failure",
"invoice_failedtoconfirm" => "notifications-invoice-failure",
"invoice_confirmed" => "notifications-invoice-settled",
"invoice_paidafterexpiration" => "notifications-invoice-settled",
"external-payout-transaction" => "notifications-payout",
"payout_awaitingapproval" => "notifications-payout",
"payout_awaitingpayment" => "notifications-payout-approved",
"newversion" => "notifications-new-version",
_ => "note"
};
}
}
<div id="NotificationsList">
@foreach (var n in Model.Last5)
{
<a asp-action="NotificationPassThrough" asp-controller="UINotifications" asp-route-id="@n.Id" class="notification d-flex align-items-center dropdown-item border-bottom border-light py-3 px-4">
<div class="me-3">
<vc:icon symbol="@NotificationIcon(n.Identifier)" />
</div>
<div class="notification-item__content">
<div class="text-start text-wrap">
@n.Body
</div>
<div class="text-start d-flex">
<small class="text-muted" data-timeago-unixms="@n.Created.ToUnixTimeMilliseconds()">@n.Created.ToTimeAgo()</small>
</div>
</div>
</a>
}
</div>

View File

@ -1,27 +0,0 @@
using System.Linq;
using System.Threading.Tasks;
using BTCPayServer.Services.Notifications;
using Microsoft.AspNetCore.Mvc;
namespace BTCPayServer.Components.Notifications
{
public class Notifications : ViewComponent
{
private readonly NotificationManager _notificationManager;
private static readonly string[] _views = { "List", "Dropdown", "Recent" };
public Notifications(NotificationManager notificationManager)
{
_notificationManager = notificationManager;
}
public async Task<IViewComponentResult> InvokeAsync(string appearance, string returnUrl)
{
var vm = await _notificationManager.GetSummaryNotifications(UserClaimsPrincipal);
vm.ReturnUrl = returnUrl;
var viewName = _views.Contains(appearance) ? appearance : _views[0];
return View(viewName, vm);
}
}
}

View File

@ -1,12 +0,0 @@
using System.Collections.Generic;
using BTCPayServer.Abstractions.Contracts;
namespace BTCPayServer.Components.Notifications
{
public class NotificationsViewModel
{
public string ReturnUrl { get; set; }
public int UnseenCount { get; set; }
public List<NotificationViewModel> Last5 { get; set; }
}
}

View File

@ -1,19 +0,0 @@
@model BTCPayServer.Components.Notifications.NotificationsViewModel
<div id="NotificationsRecent">
@if (Model.Last5.Any())
{
<div class="d-flex align-items-center justify-content-between mb-3">
<h4 class="mb-0">Recent Notifications</h4>
<a asp-controller="UINotifications" asp-action="Index">View all</a>
</div>
<partial name="Components/Notifications/List" model="Model"/>
}
else
{
<h4 class="mb-3">Notifications</h4>
<p class="text-secondary mt-3">
There are no recent unseen notifications.
</p>
}
</div>

View File

@ -23,85 +23,17 @@ namespace BTCPayServer.Controllers
[Route("notifications/{action:lowercase=Index}")]
public class UINotificationsController : Controller
{
private readonly BTCPayServerEnvironment _env;
private readonly NotificationSender _notificationSender;
private readonly UserManager<ApplicationUser> _userManager;
private readonly NotificationManager _notificationManager;
private readonly EventAggregator _eventAggregator;
public UINotificationsController(BTCPayServerEnvironment env,
NotificationSender notificationSender,
public UINotificationsController(
UserManager<ApplicationUser> userManager,
NotificationManager notificationManager,
EventAggregator eventAggregator)
NotificationManager notificationManager)
{
_env = env;
_notificationSender = notificationSender;
_userManager = userManager;
_notificationManager = notificationManager;
_eventAggregator = eventAggregator;
}
[HttpGet]
public IActionResult GetNotificationDropdownUI(string returnUrl)
{
return ViewComponent("Notifications", new { appearance = "Dropdown", returnUrl });
}
[HttpGet]
public async Task<IActionResult> SubscribeUpdates(CancellationToken cancellationToken)
{
if (!HttpContext.WebSockets.IsWebSocketRequest)
{
return BadRequest();
}
var websocket = await HttpContext.WebSockets.AcceptWebSocketAsync();
var userId = _userManager.GetUserId(User);
var websocketHelper = new WebSocketHelper(websocket);
IEventAggregatorSubscription subscription = null;
try
{
subscription = _eventAggregator.SubscribeAsync<UserNotificationsUpdatedEvent>(async evt =>
{
if (evt.UserId == userId)
{
await websocketHelper.Send("update");
}
});
await websocketHelper.NextMessageAsync(cancellationToken);
}
catch (OperationCanceledException)
{
// ignored
}
catch (WebSocketException)
{
}
finally
{
subscription?.Dispose();
await websocketHelper.DisposeAsync(CancellationToken.None);
}
return new EmptyResult();
}
#if DEBUG
[HttpGet]
public async Task<IActionResult> GenerateJunk(int x = 100, bool admin = true)
{
for (int i = 0; i < x; i++)
{
await _notificationSender.SendNotification(
admin ? (NotificationScope)new AdminScope() : new UserScope(_userManager.GetUserId(User)),
new JunkNotification());
}
return RedirectToAction("Index");
}
#endif
[HttpGet]
public async Task<IActionResult> Index(int skip = 0, int count = 50, int timezoneOffset = 0)
{

View File

@ -422,9 +422,7 @@ namespace BTCPayServer.Hosting
services.AddSingleton<INotificationHandler, PayoutNotification.Handler>();
services.AddSingleton<INotificationHandler, ExternalPayoutTransactionNotification.Handler>();
services.AddSingleton<IHostedService, DbMigrationsHostedService>();
#if DEBUG
services.AddSingleton<INotificationHandler, JunkNotification.Handler>();
#endif
services.TryAddSingleton<ExplorerClientProvider>();
services.AddSingleton<IExplorerClientProvider, ExplorerClientProvider>(x =>
x.GetRequiredService<ExplorerClientProvider>());

View File

@ -1,30 +0,0 @@
#if DEBUG
using BTCPayServer.Abstractions.Contracts;
namespace BTCPayServer.Services.Notifications.Blobs
{
internal class JunkNotification : BaseNotification
{
private const string TYPE = "junk";
internal class Handler : NotificationHandler<JunkNotification>
{
public override string NotificationType => TYPE;
public override (string identifier, string name)[] Meta
{
get
{
return new (string identifier, string name)[] { (TYPE, "Junk") };
}
}
protected override void FillViewModel(JunkNotification notification, NotificationViewModel vm)
{
vm.Body = "All your junk r belong to us!";
}
}
public override string Identifier => NotificationType;
public override string NotificationType => TYPE;
}
}
#endif

View File

@ -4,51 +4,46 @@ using System.Linq;
using System.Security.Claims;
using System.Threading.Tasks;
using BTCPayServer.Abstractions.Contracts;
using BTCPayServer.Components.Notifications;
using BTCPayServer.Data;
using Microsoft.AspNetCore.Identity;
using Microsoft.EntityFrameworkCore;
using Microsoft.Extensions.Caching.Memory;
using Newtonsoft.Json;
using Org.BouncyCastle.Crypto.Generators;
namespace BTCPayServer.Services.Notifications
{
public class NotificationManager
{
private readonly ApplicationDbContextFactory _factory;
private readonly UserManager<ApplicationUser> _userManager;
private readonly IMemoryCache _memoryCache;
private readonly EventAggregator _eventAggregator;
private readonly Dictionary<string, INotificationHandler> _handlersByNotificationType;
public NotificationManager(ApplicationDbContextFactory factory, UserManager<ApplicationUser> userManager,
public NotificationManager(ApplicationDbContextFactory factory,
IMemoryCache memoryCache, IEnumerable<INotificationHandler> handlers, EventAggregator eventAggregator)
{
_factory = factory;
_userManager = userManager;
_memoryCache = memoryCache;
_eventAggregator = eventAggregator;
_handlersByNotificationType = handlers.ToDictionary(h => h.NotificationType);
}
private const int _cacheExpiryMs = 5000;
public async Task<NotificationsViewModel> GetSummaryNotifications(ClaimsPrincipal user)
public async Task<(List<NotificationViewModel> Items, int? Count)> GetSummaryNotifications(string userId, bool cachedOnly)
{
var userId = _userManager.GetUserId(user);
var cacheKey = GetNotificationsCacheId(userId);
if (cachedOnly)
return _memoryCache.Get<(List<NotificationViewModel> Items, int? Count)>(cacheKey);
return await _memoryCache.GetOrCreateAsync(cacheKey, async entry =>
{
var resp = await GetNotifications(new NotificationsQuery
var res = await GetNotifications(new NotificationsQuery
{
Seen = false,
Skip = 0,
Take = 5,
UserId = userId
});
entry.SetAbsoluteExpiration(TimeSpan.FromMilliseconds(_cacheExpiryMs));
var res = new NotificationsViewModel { Last5 = resp.Items, UnseenCount = resp.Count.Value };
entry.Value = res;
return res;
});
@ -67,7 +62,7 @@ namespace BTCPayServer.Services.Notifications
{
return $"notifications-{userId}";
}
public const int MaxUnseen = 100;
public async Task<(List<NotificationViewModel> Items, int? Count)> GetNotifications(NotificationsQuery query)
{
await using var dbContext = _factory.CreateContext();
@ -80,6 +75,27 @@ namespace BTCPayServer.Services.Notifications
{
// Unseen notifications aren't likely to be too huge, so count should be fast
count = await queryables.withoutPaging.CountAsync();
if (count >= MaxUnseen)
{
// If we have too much unseen notifications, we don't want to show the exact count
// because it would be too long to display, so we just show 99+
// Then cleanup a bit the database by removing the oldest notifications, as it would be expensive to fetch every time
if (count >= MaxUnseen + (MaxUnseen / 2))
{
nextBatch:
var seenToRemove = await queryables.withoutPaging.OrderByDescending(data => data.Created).Skip(MaxUnseen).Take(1000).ToListAsync();
if (seenToRemove.Count > 0)
{
foreach (var seen in seenToRemove)
{
seen.Seen = true;
}
await dbContext.SaveChangesAsync();
goto nextBatch;
}
}
count = MaxUnseen;
}
}
return (Items: items, Count: count);
}

View File

@ -35,7 +35,7 @@
<vc:store-selector />
@if (_signInManager.IsSignedIn(User))
{
<vc:notifications appearance="Dropdown" return-url="@notificationsReturnUrl" />
<component type="typeof(BTCPayServer.Blazor.NotificationsDropDown)" render-mode="ServerPrerendered" />
}
</div>
<vc:main-nav />
@ -84,27 +84,6 @@
var tmpl = document.getElementById("badUrl");
mainContent.prepend(tmpl.content.cloneNode(true));
}
if ('WebSocket' in window && window.WebSocket.CLOSING === 2) {
const { host, protocol } = window.location;
var wsUri = "@_linkGenerator.GetPathByAction("SubscribeUpdates", "UINotifications", pathBase: Context.Request.PathBase)";
wsUri = (protocol === "https:" ? "wss:" : "ws:") + "//" + host + wsUri;
const newDataEndpoint = "@_linkGenerator.GetPathByAction("GetNotificationDropdownUI", "UINotifications", pathBase: Context.Request.PathBase, values: new { returnUrl = notificationsReturnUrl })";
try {
socket = new WebSocket(wsUri);
socket.onmessage = e => {
if (e.data === "ping") return;
$.get(newDataEndpoint, data => {
$("#Notifications").replaceWith($(data));
});
};
socket.onerror = e => {
console.error("Error while connecting to websocket for notifications (callback)", e);
};
}
catch (e) {
console.error("Error while connecting to websocket for notifications", e);
}
}
</script>
}
</main>