diff --git a/BTCPayServer.Tests/SeleniumTests.cs b/BTCPayServer.Tests/SeleniumTests.cs index 298e8239d..03771cf07 100644 --- a/BTCPayServer.Tests/SeleniumTests.cs +++ b/BTCPayServer.Tests/SeleniumTests.cs @@ -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); diff --git a/BTCPayServer/Blazor/BlazorExtensions.cs b/BTCPayServer/Blazor/BlazorExtensions.cs new file mode 100644 index 000000000..909745c41 --- /dev/null +++ b/BTCPayServer/Blazor/BlazorExtensions.cs @@ -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); + } + } +} diff --git a/BTCPayServer/Blazor/Icon.razor b/BTCPayServer/Blazor/Icon.razor new file mode 100644 index 000000000..894d90335 --- /dev/null +++ b/BTCPayServer/Blazor/Icon.razor @@ -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 + + + + +@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; } +} diff --git a/BTCPayServer/Blazor/NotificationsDropDown.razor b/BTCPayServer/Blazor/NotificationsDropDown.razor new file mode 100644 index 000000000..272003690 --- /dev/null +++ b/BTCPayServer/Blazor/NotificationsDropDown.razor @@ -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 _UserManager +@inject IJSRuntime _JSRuntime +@inject LinkGenerator _LinkGenerator +@inject BTCPayServerOptions _BTCPayServerOptions +@inject EventAggregator _EventAggregator + +
+ @if (UnseenCount == "0") + { + + + + } + else + { + + } + @if (UnseenCount != "0" && Last5 is not null) + { + + } +
+ +@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 Last5; + IDisposable _EventAggregatorListener; + protected override void OnInitialized() + { + if (_JSRuntime.IsPreRendering()) + return; + _EventAggregatorListener = _EventAggregator.Subscribe((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 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 + 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" + }; + } +} diff --git a/BTCPayServer/Blazor/_Imports.razor b/BTCPayServer/Blazor/_Imports.razor index 9820bfde2..6ad51cfd8 100644 --- a/BTCPayServer/Blazor/_Imports.razor +++ b/BTCPayServer/Blazor/_Imports.razor @@ -5,3 +5,5 @@ @using Microsoft.AspNetCore.Components.Routing @using Microsoft.AspNetCore.Components.Web @using Microsoft.JSInterop +@using BTCPayServer.Blazor +@using BTCPayServer.Abstractions.Extensions diff --git a/BTCPayServer/Components/Notifications/Dropdown.cshtml b/BTCPayServer/Components/Notifications/Dropdown.cshtml deleted file mode 100644 index 5a36919f8..000000000 --- a/BTCPayServer/Components/Notifications/Dropdown.cshtml +++ /dev/null @@ -1,31 +0,0 @@ -@using BTCPayServer.Views.Notifications -@using BTCPayServer.Abstractions.Extensions -@model BTCPayServer.Components.Notifications.NotificationsViewModel - -
- @if (Model.UnseenCount > 0) - { - - - } - else - { - - - - } -
diff --git a/BTCPayServer/Components/Notifications/List.cshtml b/BTCPayServer/Components/Notifications/List.cshtml deleted file mode 100644 index c1e7f6ec6..000000000 --- a/BTCPayServer/Components/Notifications/List.cshtml +++ /dev/null @@ -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" - }; - } -} -
- @foreach (var n in Model.Last5) - { - -
- -
-
-
- @n.Body -
-
- @n.Created.ToTimeAgo() -
-
-
- } -
diff --git a/BTCPayServer/Components/Notifications/Notications.cs b/BTCPayServer/Components/Notifications/Notications.cs deleted file mode 100644 index d05b43abc..000000000 --- a/BTCPayServer/Components/Notifications/Notications.cs +++ /dev/null @@ -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 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); - } - } -} diff --git a/BTCPayServer/Components/Notifications/NotificationsViewModel.cs b/BTCPayServer/Components/Notifications/NotificationsViewModel.cs deleted file mode 100644 index e2f8305a4..000000000 --- a/BTCPayServer/Components/Notifications/NotificationsViewModel.cs +++ /dev/null @@ -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 Last5 { get; set; } - } -} diff --git a/BTCPayServer/Components/Notifications/Recent.cshtml b/BTCPayServer/Components/Notifications/Recent.cshtml deleted file mode 100644 index 507284ae3..000000000 --- a/BTCPayServer/Components/Notifications/Recent.cshtml +++ /dev/null @@ -1,19 +0,0 @@ -@model BTCPayServer.Components.Notifications.NotificationsViewModel - -
- @if (Model.Last5.Any()) - { -
-

Recent Notifications

- View all -
- - } - else - { -

Notifications

-

- There are no recent unseen notifications. -

- } -
diff --git a/BTCPayServer/Controllers/UINotificationsController.cs b/BTCPayServer/Controllers/UINotificationsController.cs index 85ba34b82..e5d6dce30 100644 --- a/BTCPayServer/Controllers/UINotificationsController.cs +++ b/BTCPayServer/Controllers/UINotificationsController.cs @@ -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 _userManager; private readonly NotificationManager _notificationManager; - private readonly EventAggregator _eventAggregator; - public UINotificationsController(BTCPayServerEnvironment env, - NotificationSender notificationSender, + public UINotificationsController( UserManager 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 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(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 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 Index(int skip = 0, int count = 50, int timezoneOffset = 0) { diff --git a/BTCPayServer/Hosting/BTCPayServerServices.cs b/BTCPayServer/Hosting/BTCPayServerServices.cs index 9ae22df4a..52941b3d3 100644 --- a/BTCPayServer/Hosting/BTCPayServerServices.cs +++ b/BTCPayServer/Hosting/BTCPayServerServices.cs @@ -422,9 +422,7 @@ namespace BTCPayServer.Hosting services.AddSingleton(); services.AddSingleton(); services.AddSingleton(); -#if DEBUG - services.AddSingleton(); -#endif + services.TryAddSingleton(); services.AddSingleton(x => x.GetRequiredService()); diff --git a/BTCPayServer/Services/Notifications/Blobs/JunkNotification.cs b/BTCPayServer/Services/Notifications/Blobs/JunkNotification.cs deleted file mode 100644 index 3e1fbaa05..000000000 --- a/BTCPayServer/Services/Notifications/Blobs/JunkNotification.cs +++ /dev/null @@ -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 - { - 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 diff --git a/BTCPayServer/Services/Notifications/NotificationManager.cs b/BTCPayServer/Services/Notifications/NotificationManager.cs index f3faf55a4..8be83e875 100644 --- a/BTCPayServer/Services/Notifications/NotificationManager.cs +++ b/BTCPayServer/Services/Notifications/NotificationManager.cs @@ -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 _userManager; private readonly IMemoryCache _memoryCache; private readonly EventAggregator _eventAggregator; private readonly Dictionary _handlersByNotificationType; - public NotificationManager(ApplicationDbContextFactory factory, UserManager userManager, + public NotificationManager(ApplicationDbContextFactory factory, IMemoryCache memoryCache, IEnumerable 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 GetSummaryNotifications(ClaimsPrincipal user) + public async Task<(List Items, int? Count)> GetSummaryNotifications(string userId, bool cachedOnly) { - var userId = _userManager.GetUserId(user); var cacheKey = GetNotificationsCacheId(userId); - + if (cachedOnly) + return _memoryCache.Get<(List 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 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); } diff --git a/BTCPayServer/Views/Shared/_Layout.cshtml b/BTCPayServer/Views/Shared/_Layout.cshtml index f5f05aadb..d9a7cb245 100644 --- a/BTCPayServer/Views/Shared/_Layout.cshtml +++ b/BTCPayServer/Views/Shared/_Layout.cshtml @@ -35,7 +35,7 @@ @if (_signInManager.IsSignedIn(User)) { - + } @@ -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); - } - } }