Repository: btcpayserver/btcpayserver (csharp)

Issue:
Improved Notifications List View
For the developer who picks up this Issue, feel free to reach out to me on Mattermost @dstrukt for any feedback, additional mocks, or discussion of specific items!

Few things involved in this improvement, but all the changes should be consistent with what we're doing in the Invoice / Payment Request views already, so that could act as a reference for code / implementation:

- [x] Mass Action buttons that replace the table header when `> 1` are selected
- [x] Introduction of the `Store` from which the notification originated to the table column ... might just be adding more noise though, but an option, thoughts?
- [x] Ability to filter based on the `Store`, regardless if we add to the column
- [x] A `Read/Unread` or `Seen/Not Seen` toggle, would be similar styling to the `Store Invoices / All Invoices` in the Invoice List View, but this is also an optional choice .. thoughts?
- [x] Filter by the `type` e.g. `Invoice`, `Payment Request`, etc.. similar functionality to the filtering in other views

.gif might take a second load

![Notifications+MassActionsV0 1](https://user-images.githubusercontent.com/6250771/173712851-e3641f1c-c92d-447c-a60a-4cb9e6043f40.gif)

Pull request description:
Resolves #3871 

@dstrukt did Include the store column, as not all notifications are associated with a store.


But outside of that, open to review.

Patch:
diff --git a/BTCPayServer.Abstractions/Contracts/INotificationHandler.cs b/BTCPayServer.Abstractions/Contracts/INotificationHandler.cs
index 49b92220b6..da4c1ffceb 100644
--- a/BTCPayServer.Abstractions/Contracts/INotificationHandler.cs
+++ b/BTCPayServer.Abstractions/Contracts/INotificationHandler.cs
@@ -25,5 +25,6 @@ public class NotificationViewModel
         public string Body { get; set; }
         public string ActionLink { get; set; }
         public bool Seen { get; set; }
+        public string StoreId { get; set; }
     }
 }
diff --git a/BTCPayServer.Data/Data/NotificationData.cs b/BTCPayServer.Data/Data/NotificationData.cs
index 3e099980fa..154c87357c 100644
--- a/BTCPayServer.Data/Data/NotificationData.cs
+++ b/BTCPayServer.Data/Data/NotificationData.cs
@@ -2,7 +2,6 @@
 using System.ComponentModel.DataAnnotations;
 using Microsoft.EntityFrameworkCore;
 using Microsoft.EntityFrameworkCore.Infrastructure;
-using Microsoft.EntityFrameworkCore.Storage;
 
 namespace BTCPayServer.Data
 {
@@ -23,7 +22,6 @@ public class NotificationData : IHasBlobUntyped
         public byte[] Blob { get; set; }
         public string Blob2 { get; set; }
 
-
         internal static void OnModelCreating(ModelBuilder builder, DatabaseFacade databaseFacade)
         {
             builder.Entity<NotificationData>()
diff --git a/BTCPayServer/Controllers/UINotificationsController.cs b/BTCPayServer/Controllers/UINotificationsController.cs
index e5d6dce30b..02f83e18e4 100644
--- a/BTCPayServer/Controllers/UINotificationsController.cs
+++ b/BTCPayServer/Controllers/UINotificationsController.cs
@@ -1,17 +1,13 @@
 using System;
 using System.Linq;
-using System.Net.WebSockets;
-using System.Threading;
 using System.Threading.Tasks;
 using BTCPayServer.Abstractions.Constants;
 using BTCPayServer.Client;
 using BTCPayServer.Data;
 using BTCPayServer.Filters;
 using BTCPayServer.Models.NotificationViewModels;
-using BTCPayServer.Security;
-using BTCPayServer.Services;
 using BTCPayServer.Services.Notifications;
-using BTCPayServer.Services.Notifications.Blobs;
+using BTCPayServer.Services.Stores;
 using Microsoft.AspNetCore.Authorization;
 using Microsoft.AspNetCore.Identity;
 using Microsoft.AspNetCore.Mvc;
@@ -23,35 +19,59 @@ namespace BTCPayServer.Controllers
     [Route("notifications/{action:lowercase=Index}")]
     public class UINotificationsController : Controller
     {
+        private readonly ApplicationDbContextFactory _factory;
+        private readonly StoreRepository _storeRepo;
         private readonly UserManager<ApplicationUser> _userManager;
         private readonly NotificationManager _notificationManager;
 
         public UINotificationsController(
+            StoreRepository storeRepo,
             UserManager<ApplicationUser> userManager,
-            NotificationManager notificationManager)
+            NotificationManager notificationManager,
+            ApplicationDbContextFactory factory)
         {
+            _storeRepo = storeRepo;
             _userManager = userManager;
             _notificationManager = notificationManager;
+            _factory = factory;
         }
 
         [HttpGet]
-        public async Task<IActionResult> Index(int skip = 0, int count = 50, int timezoneOffset = 0)
+        public async Task<IActionResult> Index(NotificationIndexViewModel model = null)
         {
+            model ??= new NotificationIndexViewModel { Skip = 0 };
+            var timezoneOffset = model.TimezoneOffset ?? 0;
+            model.Status ??= "Unread";
+            ViewBag.Status = model.Status;
             if (!ValidUserClaim(out var userId))
                 return RedirectToAction("Index", "UIHome");
 
-            var res = await _notificationManager.GetNotifications(new NotificationsQuery()
+            var stores = await _storeRepo.GetStoresByUserId(userId);
+            model.Stores = stores.Where(store => !store.Archived).OrderBy(s => s.StoreName).ToList();
+
+
+            await using var dbContext = _factory.CreateContext();
+
+            var searchTerm = string.IsNullOrEmpty(model.SearchText) ? model.SearchTerm : $"{model.SearchText},{model.SearchTerm}";
+            var fs = new SearchString(searchTerm, timezoneOffset);
+            model.Search = fs;
+
+            var res = await _notificationManager.GetNotifications(new NotificationsQuery
             {
-                Skip = skip,
-                Take = count,
-                UserId = userId
+                Skip = model.Skip,
+                Take = model.Count,
+                UserId = userId,
+                SearchText = model.SearchText,
+                Type = fs.GetFilterArray("type"),
+                Stores = fs.GetFilterArray("store"),
+                Seen = model.Status == "Unread" ? false : null
             });
-
-            var model = new IndexViewModel() { Skip = skip, Count = count, Items = res.Items, Total = res.Count };
+            model.Items = res.Items;
 
             return View(model);
         }
 
+
         [HttpPost]
         [Authorize(AuthenticationSchemes = AuthenticationSchemes.Cookie, Policy = Policies.CanManageNotificationsForUser)]
         public async Task<IActionResult> FlipRead(string id)
diff --git a/BTCPayServer/HostedServices/InvoiceWatcher.cs b/BTCPayServer/HostedServices/InvoiceWatcher.cs
index a92c2cc2d1..f966f85a9b 100644
--- a/BTCPayServer/HostedServices/InvoiceWatcher.cs
+++ b/BTCPayServer/HostedServices/InvoiceWatcher.cs
@@ -237,7 +237,7 @@ public Task StartAsync(CancellationToken cancellationToken)
                 if (InvoiceEventNotification.HandlesEvent(b.Name))
                 {
                     await _notificationSender.SendNotification(new StoreScope(b.Invoice.StoreId),
-                        new InvoiceEventNotification(b.Invoice.Id, b.Name));
+                        new InvoiceEventNotification(b.Invoice.Id, b.Name, b.Invoice.StoreId));
                 }
                 if (b.Name == InvoiceEvent.Created)
                 {
diff --git a/BTCPayServer/Models/NotificationViewModels/IndexViewModel.cs b/BTCPayServer/Models/NotificationViewModels/IndexViewModel.cs
index 4b08a1986e..6365e0596c 100644
--- a/BTCPayServer/Models/NotificationViewModels/IndexViewModel.cs
+++ b/BTCPayServer/Models/NotificationViewModels/IndexViewModel.cs
@@ -1,12 +1,20 @@
 using System.Collections.Generic;
 using BTCPayServer.Abstractions.Contracts;
+using BTCPayServer.Data;
 
 namespace BTCPayServer.Models.NotificationViewModels
 {
     public class IndexViewModel : BasePagingViewModel
     {
-        public List<NotificationViewModel> Items { get; set; }
-
+        public List<NotificationViewModel> Items { get; set; } = [];
+        public string SearchText { get; set; }
+        public string Status { get; set; }
+        public SearchString Search { get; set; }
         public override int CurrentPageCount => Items.Count;
     }
+
+    public class NotificationIndexViewModel : IndexViewModel
+    {
+        public List<StoreData> Stores { get; set; }
+    }
 }
diff --git a/BTCPayServer/Services/Notifications/Blobs/ExternalPayoutTransactionNotification.cs b/BTCPayServer/Services/Notifications/Blobs/ExternalPayoutTransactionNotification.cs
index c94c66e4ee..6c0fcb75fd 100644
--- a/BTCPayServer/Services/Notifications/Blobs/ExternalPayoutTransactionNotification.cs
+++ b/BTCPayServer/Services/Notifications/Blobs/ExternalPayoutTransactionNotification.cs
@@ -36,6 +36,7 @@ protected override void FillViewModel(ExternalPayoutTransactionNotification noti
             {
                 vm.Identifier = notification.Identifier;
                 vm.Type = notification.NotificationType;
+                vm.StoreId = notification.StoreId;
                 vm.Body =
                     "A payment that was made to an approved payout by an external wallet is waiting for your confirmation.";
                 vm.ActionLink = _linkGenerator.GetPathByAction(nameof(UIStorePullPaymentsController.Payouts),
diff --git a/BTCPayServer/Services/Notifications/Blobs/InviteAcceptedNotification.cs b/BTCPayServer/Services/Notifications/Blobs/InviteAcceptedNotification.cs
index 41701f3432..8dfa325b36 100644
--- a/BTCPayServer/Services/Notifications/Blobs/InviteAcceptedNotification.cs
+++ b/BTCPayServer/Services/Notifications/Blobs/InviteAcceptedNotification.cs
@@ -44,6 +44,7 @@ protected override void FillViewModel(InviteAcceptedNotification notification, N
         {
             vm.Identifier = notification.Identifier;
             vm.Type = notification.NotificationType;
+            vm.StoreId = notification.StoreId;
             vm.Body = $"User {notification.UserEmail} accepted the invite to {notification.StoreName}.";
             vm.ActionLink = linkGenerator.GetPathByAction(nameof(UIStoresController.StoreUsers),
                 "UIStores",
diff --git a/BTCPayServer/Services/Notifications/Blobs/InvoiceEventNotification.cs b/BTCPayServer/Services/Notifications/Blobs/InvoiceEventNotification.cs
index 2fe197a16c..ab477ed2a0 100644
--- a/BTCPayServer/Services/Notifications/Blobs/InvoiceEventNotification.cs
+++ b/BTCPayServer/Services/Notifications/Blobs/InvoiceEventNotification.cs
@@ -54,6 +54,7 @@ protected override void FillViewModel(InvoiceEventNotification notification,
                 }
                 vm.Identifier = notification.Identifier;
                 vm.Type = notification.NotificationType;
+                vm.StoreId = notification?.StoreId;
                 vm.ActionLink = _linkGenerator.GetPathByAction(nameof(UIInvoiceController.Invoice),
                     "UIInvoice",
                     new { invoiceId = notification.InvoiceId }, _options.RootPath);
@@ -64,10 +65,11 @@ public InvoiceEventNotification()
         {
         }
 
-        public InvoiceEventNotification(string invoiceId, string invoiceEvent)
+        public InvoiceEventNotification(string invoiceId, string invoiceEvent, string storeId)
         {
             InvoiceId = invoiceId;
             Event = invoiceEvent;
+            StoreId = storeId;
         }
 
         public static bool HandlesEvent(string invoiceEvent)
@@ -77,6 +79,7 @@ public static bool HandlesEvent(string invoiceEvent)
 
         public string InvoiceId { get; set; }
         public string Event { get; set; }
+        public string StoreId { get; set; }
         public override string Identifier => Event is null ? TYPE : Event.ToStringLowerInvariant();
         public override string NotificationType => TYPE;
     }
diff --git a/BTCPayServer/Services/Notifications/Blobs/PayoutNotification.cs b/BTCPayServer/Services/Notifications/Blobs/PayoutNotification.cs
index 719f7b7497..8514684e21 100644
--- a/BTCPayServer/Services/Notifications/Blobs/PayoutNotification.cs
+++ b/BTCPayServer/Services/Notifications/Blobs/PayoutNotification.cs
@@ -36,6 +36,7 @@ protected override void FillViewModel(PayoutNotification notification, Notificat
             {
                 vm.Identifier = notification.Identifier;
                 vm.Type = notification.NotificationType;
+                vm.StoreId = notification.StoreId;
                 vm.Body = (notification.Status ?? PayoutState.AwaitingApproval) switch
                 {
                     PayoutState.AwaitingApproval => "A new payout is awaiting for approval",
diff --git a/BTCPayServer/Services/Notifications/NotificationManager.cs b/BTCPayServer/Services/Notifications/NotificationManager.cs
index e4ecf45831..0b5b62ab8b 100644
--- a/BTCPayServer/Services/Notifications/NotificationManager.cs
+++ b/BTCPayServer/Services/Notifications/NotificationManager.cs
@@ -1,14 +1,11 @@
 using System;
 using System.Collections.Generic;
 using System.Linq;
-using System.Security.Claims;
 using System.Threading.Tasks;
 using BTCPayServer.Abstractions.Contracts;
 using BTCPayServer.Data;
-using Microsoft.AspNetCore.Identity;
 using Microsoft.EntityFrameworkCore;
 using Microsoft.Extensions.Caching.Memory;
-using Newtonsoft.Json;
 
 namespace BTCPayServer.Services.Notifications
 {
@@ -28,7 +25,6 @@ public NotificationManager(ApplicationDbContextFactory factory,
             _handlersByNotificationType = handlers.ToDictionary(h => h.NotificationType);
         }
 
-
         public async Task<(List<NotificationViewModel> Items, int? Count)> GetSummaryNotifications(string userId, bool cachedOnly)
         {
             var cacheKey = GetNotificationsCacheId(userId);
@@ -68,7 +64,7 @@ private static string GetNotificationsCacheId(string userId)
 
             var queryables = GetNotificationsQueryable(dbContext, query);
             var items = (await queryables.withPaging.ToListAsync()).Select(ToViewModel).Where(model => model != null).ToList();
-
+            items = FilterNotifications(items, query);
             int? count = null;
             if (query.Seen is false)
             {
@@ -134,6 +130,34 @@ private static string GetNotificationsCacheId(string userId)
             return (queryable, queryable2);
         }
 
+        private List<NotificationViewModel> FilterNotifications(List<NotificationViewModel> notifications, NotificationsQuery query)
+        {
+            if (!string.IsNullOrEmpty(query.SearchText))
+            {
+                notifications = notifications.Where(data => data.Body.Contains(query.SearchText)).ToList();
+            }
+            if (query.Type?.Length > 0)
+            {
+                if (query.Type?.Length > 0)
+                {
+                    if (query.Type.Contains("userupdate"))
+                    {
+                        notifications = notifications.Where(n => n.Type.Equals("inviteaccepted", StringComparison.OrdinalIgnoreCase) ||
+                                                                 n.Type.Equals("userapproval", StringComparison.OrdinalIgnoreCase)).ToList();
+                    }
+                    else
+                    {
+                        notifications = notifications.Where(n => query.Type.Contains(n.Type, StringComparer.OrdinalIgnoreCase)).ToList();
+                    }
+                }
+            }
+            if (query.Stores?.Length > 0)
+            {
+                notifications = notifications.Where(n => !string.IsNullOrEmpty(n.StoreId) && query.Stores.Contains(n.StoreId, StringComparer.OrdinalIgnoreCase)).ToList();
+            }
+            return notifications;
+        }
+
         public async Task<List<NotificationViewModel>> ToggleSeen(NotificationsQuery notificationsQuery, bool? setSeen)
         {
             await using var dbContext = _factory.CreateContext();
@@ -195,5 +219,8 @@ public class NotificationsQuery
         public int? Skip { get; set; }
         public int? Take { get; set; }
         public bool? Seen { get; set; }
+        public string SearchText { get; set; }
+        public string[] Type { get; set; }
+        public string[] Stores { get; set; }
     }
 }
diff --git a/BTCPayServer/Services/Notifications/NotificationSender.cs b/BTCPayServer/Services/Notifications/NotificationSender.cs
index 0ad148eff3..c020192067 100644
--- a/BTCPayServer/Services/Notifications/NotificationSender.cs
+++ b/BTCPayServer/Services/Notifications/NotificationSender.cs
@@ -58,6 +58,11 @@ public async Task SendNotification(INotificationScope scope, BaseNotification no
             }
         }
 
+        public BaseNotification GetBaseNotification(NotificationData notificationData)
+        {
+            return notificationData.HasTypedBlob<BaseNotification>().GetBlob();
+        }
+
         private async Task<string[]> GetUsers(INotificationScope scope, string notificationIdentifier)
         {
             await using var ctx = _contextFactory.CreateContext();
diff --git a/BTCPayServer/Views/UINotifications/Index.cshtml b/BTCPayServer/Views/UINotifications/Index.cshtml
index 8a2071e01b..a376b9e4af 100644
--- a/BTCPayServer/Views/UINotifications/Index.cshtml
+++ b/BTCPayServer/Views/UINotifications/Index.cshtml
@@ -1,38 +1,137 @@
-@model BTCPayServer.Models.NotificationViewModels.IndexViewModel
+@model BTCPayServer.Models.NotificationViewModels.NotificationIndexViewModel
 @{
     ViewData["Title"] = "Notifications";
+    string status = ViewBag.Status;
+    var statusFilterCount = CountArrayFilter("type");
+    var storesFilterCount = CountArrayFilter("store");
 }
 
-<partial name="_StatusMessage" />
+@functions
+{
+    private int CountArrayFilter(string type) =>
+        Model.Search.ContainsFilter(type) ? Model.Search.GetFilterArray(type).Length : 0;
+
+    private bool HasArrayFilter(string type, string key = null) =>
+        Model.Search.ContainsFilter(type) && (key is null || Model.Search.GetFilterArray(type).Contains(key));
+}
 
-<div class="d-flex flex-wrap align-items-center justify-content-between mb-2">
+@section PageHeadContent
+{
+    <style>
+        .dropdown > .btn {
+            min-width: 7rem;
+            padding-left: 1rem;
+            text-align: left;
+        }
+        @@media (max-width: 568px) {
+            #SearchText {
+                width: 100%;
+            }
+        } 
+    </style>
+}
+
+<div class="sticky-header">
     <h2 class="my-1">@ViewData["Title"]</h2>
-    <a id="NotificationSettings" asp-controller="UIManage" asp-action="NotificationSettings" class="btn btn-secondary">
-        <vc:icon symbol="settings" />                
+    <a id="NotificationSettings" asp-controller="UIManage" asp-action="NotificationSettings" class="btn btn-secondary d-flex align-items-center">
+        <vc:icon symbol="nav-store-settings" />
     </a>
 </div>
+<partial name="_StatusMessage" />
+<form asp-route-status="@Model.Status" class="d-flex flex-wrap align-items-center gap-md-4 gap-sm-0 mb-4 col-xxl-8" asp-action="Index" method="get">
+    <input asp-for="Count" type="hidden" />
+    <input asp-for="TimezoneOffset" type="hidden" />
+    <input type="hidden" asp-for="Status" value="@Model.Status" />
+
+    <div class="col-12 col-md-6 col-lg-4 mb-3 mb-md-0">
+        <input asp-for="SearchText" class="form-control" placeholder="Search…" />
+    </div>
+
+    <div class="btn-group col-12 col-md-auto mb-3 mb-md-0" role="group" aria-label="View Notification">
+        <a class="btn @((status == "All") ? "btn-primary" : "btn-outline-secondary")"
+           asp-controller="UINotifications"
+           asp-action="Index"
+           asp-route-Status="All">
+            All
+        </a>
+        <a class="btn @((status == "Unread") ? "btn-primary" : "btn-outline-secondary")"
+           asp-controller="UINotifications"
+           asp-action="Index"
+           asp-route-Status="Unread">
+            Unread
+        </a>
+    </div>
+
+    <div class="dropdown col-12 col-md-auto mb-3 mb-md-0">
+        <button id="StatusOptionsToggle" class="btn btn-secondary dropdown-toggle dropdown-toggle-custom-caret w-100 w-md-auto" type="button" data-bs-toggle="dropdown" aria-expanded="false">
+            @if (statusFilterCount > 0)
+            {
+                <span>@statusFilterCount Type</span>
+            }
+            else
+            {
+                <span>All Type</span>
+            }
+        </button>
+        <div class="dropdown-menu" aria-labelledby="StatusOptionsToggle">
+            <a asp-action="Index" asp-route-count="@Model.Count" asp-route-status="@Model.Status" asp-route-searchTerm="@Model.Search.Toggle("type", "invoicestate")" class="dropdown-item @(HasArrayFilter("type", "invoicestate") ? "custom-active" : "")">Invoice</a>
+            <a asp-action="Index" asp-route-count="@Model.Count" asp-route-status="@Model.Status" asp-route-searchTerm="@Model.Search.Toggle("type", "payout")" class="dropdown-item @(HasArrayFilter("type", "payout") ? "custom-active" : "")">Payouts</a>
+            <a asp-action="Index" asp-route-count="@Model.Count" asp-route-status="@Model.Status" asp-route-searchTerm="@Model.Search.Toggle("type", "newversion")" class="dropdown-item @(HasArrayFilter("type", "newversion") ? "custom-active" : "")">New Version</a>
+            <a asp-action="Index" asp-route-count="@Model.Count" asp-route-status="@Model.Status" asp-route-searchTerm="@Model.Search.Toggle("type", "pluginupdate")" class="dropdown-item @(HasArrayFilter("type", "pluginupdate") ? "custom-active" : "")">Plugin Updates</a>
+            <a asp-action="Index" asp-route-count="@Model.Count" asp-route-status="@Model.Status" asp-route-searchTerm="@Model.Search.Toggle("type", "userupdate")" class="dropdown-item @(HasArrayFilter("type", "userupdate") ? "custom-active" : "")">User Updates</a>
+        </div>
+    </div>
+
+    <div class="dropdown col-12 col-md-auto">
+        <button id="StoresOptionsToggle" class="btn btn-secondary dropdown-toggle dropdown-toggle-custom-caret w-100 w-md-auto" type="button" data-bs-toggle="dropdown" aria-expanded="false">
+            @if (storesFilterCount > 0)
+            {
+                <span>@storesFilterCount Store</span>
+            }
+            else
+            {
+                <span>All Stores</span>
+            }
+        </button>
+        <div class="dropdown-menu" aria-labelledby="StoresOptionsToggle">
+            @foreach (var store in Model.Stores)
+            {
+                <a asp-action="Index"
+                   asp-route-count="@Model.Count"
+                   asp-route-status="@Model.Status"
+                   asp-route-searchTerm="@Model.Search.Toggle("store", store.Id)"
+                   class="dropdown-item @(HasArrayFilter("store", store.Id) ? "custom-active" : "")">
+                    @store.StoreName
+                </a>
+            }
+        </div>
+    </div>
+</form>
+
+
+
 @if (Model.Items.Count > 0)
 {
     <form method="post" asp-action="MassAction">
         @if (Model.Items.Any())
         {
-            <div class="table-responsive-md">
+            <div class="table-responsive">
                 <table class="table table-hover mass-action">
                     <thead class="mass-action-head">
                         <tr>
                             <th class="mass-action-select-col only-for-js">
                                 <input name="selectedItems" type="checkbox" class="form-check-input mass-action-select-all" />
                             </th>
+                            <th>Message</th>
                             <th class="date-col">
                                 <div class="d-flex align-items-center gap-1">
                                     Date
-                                    <button type="button" class="btn btn-link p-0 switch-time-format" title="Switch date format">
+                                    <button type="button" class="btn btn-link p-0 switch-time-format only-for-js" title="Switch date format">
                                         <vc:icon symbol="time" />
                                     </button>
                                 </div>
                             </th>
-                            <th>Message</th>
-                            <th></th>
+                            <th class="text-end text-success">Actions</th>
                         </tr>
                     </thead>
                     <thead class="mass-action-actions">
@@ -40,30 +139,31 @@
                             <th class="mass-action-select-col only-for-js">
                                 <input type="checkbox" class="form-check-input mass-action-select-all" />
                             </th>
-                            <th colspan="6">
-                                <div class="d-flex flex-wrap align-items-center justify-content-between gap-3">
-                                    <div>
-                                        <strong class="mass-action-selected-count">0</strong>
-                                        selected
-                                    </div>
+                            <th colspan="3">
+                                <div class="d-flex flex-wrap align-items-center justify-content-start gap-3">
                                     <div class="d-inline-flex align-items-center gap-3">
-                                        <button type="submit" name="command" value="mark-seen" class="btn btn-link gap-1">
-                                            <vc:icon symbol="actions-show" />
-                                            Mark seen
-                                        </button>
-                                        <button type="submit" name="command" value="mark-unseen" class="btn btn-link gap-1">
-                                            <vc:icon symbol="actions-hide" />
-                                            Mark unseen
-                                        </button>
+                                        @if (Model.Status == "Unread")
+                                        {
+                                            <button type="submit" name="command" value="mark-seen" class="btn btn-link gap-1">
+                                                <vc:icon symbol="actions-show" />
+                                                Mark as seen
+                                            </button>
+                                        }
                                         <button type="submit" name="command" value="delete" class="btn btn-link gap-1">
                                             <vc:icon symbol="actions-trash" />
                                             Delete
                                         </button>
                                     </div>
+                                    <div class="vr"></div>
+                                    <div>
+                                        <strong class="mass-action-selected-count">0</strong>
+                                        selected
+                                    </div>
                                 </div>
                             </th>
                         </tr>
                     </thead>
+
                     <tbody>
                         @foreach (var item in Model.Items)
                         {
@@ -71,19 +171,23 @@
                                 <td class="only-for-js mass-action-select-col">
                                     <input name="selectedItems" type="checkbox" class="form-check-input mass-action-select" value="@item.Id" />
                                 </td>
-                                <td class="date-col">@item.Created.ToBrowserDate()</td>
                                 <td>
                                     @item.Body
                                 </td>
+                                <td class="date-col fw-normal">@item.Created.ToTimeAgo()</td>
+
                                 <td class="text-end">
                                     <div class="d-inline-flex align-items-center gap-3">
-                                        <button class="btn btn-link p-0 btn-toggle-seen" type="submit" name="command" value="flip-individual:@(item.Id)">
-                                            <span>Mark</span>&nbsp;<span class="seen-text"></span>
-                                        </button>
                                         @if (!string.IsNullOrEmpty(item.ActionLink))
                                         {
                                             <a href="@item.ActionLink" class="btn btn-link p-0" rel="noreferrer noopener">Details</a>
                                         }
+                                        @if (!item.Seen)
+                                        {
+                                            <button class="btn btn-link p-0 btn-toggle-seen text-nowrap" type="submit" name="command" value="flip-individual:@(item.Id)">
+                                                <span>Mark as seen</span>
+                                            </button>
+                                        }
                                     </div>
                                 </td>
                             </tr>
@@ -103,20 +207,15 @@ else
     </p>
 }
 
+
+
+
 <style>
     .notification-row.loading {
         cursor: wait;
         pointer-events: none;
     }
 
-    .seen-text::after {
-        content: "seen";
-    }
-
-    tr.seen td .seen-text::after {
-        content: "unseen";
-    }
-
     tr td {
         font-weight: bold;
     }
diff --git a/BTCPayServer/wwwroot/main/site.css b/BTCPayServer/wwwroot/main/site.css
index 83739bbf02..672c78e5c8 100644
--- a/BTCPayServer/wwwroot/main/site.css
+++ b/BTCPayServer/wwwroot/main/site.css
@@ -128,6 +128,11 @@ a.unobtrusive-link {
     transform: rotate(-180deg);
 }
 
+/* Time format button */
+.switch-time-format {
+    line-height: 1;
+}
+
 /* Icon and text, used for warnings of additional info text. Adjust spacing and color via utility classes. */
 .info-note {
     display: inline-flex;


Test patch:
diff --git a/BTCPayServer.Tests/UnitTest1.cs b/BTCPayServer.Tests/UnitTest1.cs
index 5c371963c7..5408bb5f41 100644
--- a/BTCPayServer.Tests/UnitTest1.cs
+++ b/BTCPayServer.Tests/UnitTest1.cs
@@ -2461,7 +2461,7 @@ public async Task CanCheckForNewVersion()
             var ctrl = acc.GetController<UINotificationsController>();
             var newVersion = MockVersionFetcher.MOCK_NEW_VERSION;
 
-            var vm = Assert.IsType<Models.NotificationViewModels.IndexViewModel>(
+            var vm = Assert.IsType<Models.NotificationViewModels.NotificationIndexViewModel>(
                 Assert.IsType<ViewResult>(await ctrl.Index()).Model);
 
             Assert.True(vm.Skip == 0);