mirror of
https://github.com/aljazceru/btcpayserver.git
synced 2025-12-18 06:24:24 +01:00
Improve notifications UI (#1678)
* Add No JS support for notification seen toggle * Invalidate cache when user toggles seen/massaction * mark notif as seen after click * add additional mass actions * fix formatting * add pav changes * invalidate cache on new notifs
This commit is contained in:
@@ -1,18 +1,13 @@
|
||||
using System;
|
||||
using System.Collections.Generic;
|
||||
using System.Linq;
|
||||
using System.Security.Claims;
|
||||
using System.Text;
|
||||
using System.Threading.Tasks;
|
||||
using BTCPayServer.Data;
|
||||
using BTCPayServer.Filters;
|
||||
using BTCPayServer.HostedServices;
|
||||
using BTCPayServer.Models.NotificationViewModels;
|
||||
using BTCPayServer.Security;
|
||||
using BTCPayServer.Services;
|
||||
using BTCPayServer.Services.Notifications;
|
||||
using BTCPayServer.Services.Notifications.Blobs;
|
||||
using Google;
|
||||
using Microsoft.AspNetCore.Authorization;
|
||||
using Microsoft.AspNetCore.Identity;
|
||||
using Microsoft.AspNetCore.Mvc;
|
||||
@@ -21,6 +16,7 @@ namespace BTCPayServer.Controllers
|
||||
{
|
||||
[BitpayAPIConstraint(false)]
|
||||
[Authorize(AuthenticationSchemes = AuthenticationSchemes.Cookie)]
|
||||
[Route("[controller]/[action]")]
|
||||
public class NotificationsController : Controller
|
||||
{
|
||||
private readonly BTCPayServerEnvironment _env;
|
||||
@@ -58,7 +54,7 @@ namespace BTCPayServer.Controllers
|
||||
.Where(a => a.ApplicationUserId == userId)
|
||||
.Select(a => _notificationManager.ToViewModel(a))
|
||||
.ToList(),
|
||||
Total = _db.Notifications.Where(a => a.ApplicationUserId == userId).Count()
|
||||
Total = _db.Notifications.Count(a => a.ApplicationUserId == userId)
|
||||
};
|
||||
|
||||
return View(model);
|
||||
@@ -81,24 +77,81 @@ namespace BTCPayServer.Controllers
|
||||
var notif = _db.Notifications.Single(a => a.Id == id && a.ApplicationUserId == userId);
|
||||
notif.Seen = !notif.Seen;
|
||||
await _db.SaveChangesAsync();
|
||||
}
|
||||
|
||||
_notificationManager.InvalidateNotificationCache(userId);
|
||||
return RedirectToAction(nameof(Index));
|
||||
}
|
||||
|
||||
return BadRequest();
|
||||
}
|
||||
|
||||
[HttpGet]
|
||||
public async Task<IActionResult> NotificationPassThrough(string id)
|
||||
{
|
||||
if (ValidUserClaim(out var userId))
|
||||
{
|
||||
var notif = _db.Notifications.Single(a => a.Id == id && a.ApplicationUserId == userId);
|
||||
if (!notif.Seen)
|
||||
{
|
||||
notif.Seen = !notif.Seen;
|
||||
await _db.SaveChangesAsync();
|
||||
_notificationManager.InvalidateNotificationCache(userId);
|
||||
}
|
||||
|
||||
var vm = _notificationManager.ToViewModel(notif);
|
||||
if (string.IsNullOrEmpty(vm.ActionLink))
|
||||
{
|
||||
return RedirectToAction(nameof(Index));
|
||||
}
|
||||
|
||||
return Redirect(vm.ActionLink);
|
||||
}
|
||||
|
||||
return NotFound();
|
||||
}
|
||||
|
||||
|
||||
[HttpPost]
|
||||
public async Task<IActionResult> MassAction(string command, string[] selectedItems)
|
||||
{
|
||||
if (!ValidUserClaim(out var userId))
|
||||
{
|
||||
return NotFound();
|
||||
}
|
||||
|
||||
if (command.StartsWith("flip-individual", StringComparison.InvariantCulture))
|
||||
{
|
||||
var id = command.Split(":")[1];
|
||||
return await FlipRead(id);
|
||||
}
|
||||
|
||||
if (selectedItems != null)
|
||||
{
|
||||
if (command == "delete" && ValidUserClaim(out var userId))
|
||||
var items = _db.Notifications.Where(a => a.ApplicationUserId == userId && selectedItems.Contains(a.Id));
|
||||
switch (command)
|
||||
{
|
||||
var toRemove = _db.Notifications.Where(a => a.ApplicationUserId == userId && selectedItems.Contains(a.Id));
|
||||
_db.Notifications.RemoveRange(toRemove);
|
||||
await _db.SaveChangesAsync();
|
||||
case "delete":
|
||||
_db.Notifications.RemoveRange(items);
|
||||
|
||||
return RedirectToAction(nameof(Index));
|
||||
break;
|
||||
case "mark-seen":
|
||||
foreach (NotificationData notificationData in items)
|
||||
{
|
||||
notificationData.Seen = true;
|
||||
}
|
||||
|
||||
break;
|
||||
case "mark-unseen":
|
||||
foreach (NotificationData notificationData in items)
|
||||
{
|
||||
notificationData.Seen = false;
|
||||
}
|
||||
|
||||
break;
|
||||
}
|
||||
|
||||
await _db.SaveChangesAsync();
|
||||
_notificationManager.InvalidateNotificationCache(userId);
|
||||
return RedirectToAction(nameof(Index));
|
||||
}
|
||||
|
||||
return RedirectToAction(nameof(Index));
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
using System;
|
||||
using System.Collections.Generic;
|
||||
using System.Globalization;
|
||||
using System.Linq;
|
||||
using System.Security.Claims;
|
||||
using System.Text;
|
||||
@@ -22,7 +23,8 @@ namespace BTCPayServer.Services.Notifications
|
||||
private readonly Dictionary<string, INotificationHandler> _handlersByNotificationType;
|
||||
private readonly Dictionary<Type, INotificationHandler> _handlersByBlobType;
|
||||
|
||||
public NotificationManager(ApplicationDbContextFactory factory, UserManager<ApplicationUser> userManager, IMemoryCache memoryCache, IEnumerable<INotificationHandler> handlers)
|
||||
public NotificationManager(ApplicationDbContextFactory factory, UserManager<ApplicationUser> userManager,
|
||||
IMemoryCache memoryCache, IEnumerable<INotificationHandler> handlers)
|
||||
{
|
||||
_factory = factory;
|
||||
_userManager = userManager;
|
||||
@@ -32,19 +34,31 @@ namespace BTCPayServer.Services.Notifications
|
||||
}
|
||||
|
||||
private const int _cacheExpiryMs = 5000;
|
||||
|
||||
public async Task<NotificationSummaryViewModel> GetSummaryNotifications(ClaimsPrincipal user)
|
||||
{
|
||||
var userId = _userManager.GetUserId(user);
|
||||
|
||||
if (_memoryCache.TryGetValue<NotificationSummaryViewModel>(userId, out var obj))
|
||||
var cacheKey = GetNotificationsCacheId(userId);
|
||||
if (_memoryCache.TryGetValue<NotificationSummaryViewModel>(cacheKey, out var obj))
|
||||
return obj;
|
||||
|
||||
var resp = await FetchNotificationsFromDb(userId);
|
||||
_memoryCache.Set(userId, resp, new MemoryCacheEntryOptions().SetAbsoluteExpiration(TimeSpan.FromMilliseconds(_cacheExpiryMs)));
|
||||
_memoryCache.Set(cacheKey, resp,
|
||||
new MemoryCacheEntryOptions().SetAbsoluteExpiration(TimeSpan.FromMilliseconds(_cacheExpiryMs)));
|
||||
|
||||
return resp;
|
||||
}
|
||||
|
||||
public void InvalidateNotificationCache(string userId)
|
||||
{
|
||||
_memoryCache.Remove(GetNotificationsCacheId(userId));
|
||||
}
|
||||
|
||||
private static string GetNotificationsCacheId(string userId)
|
||||
{
|
||||
return $"notifications-{userId}";
|
||||
}
|
||||
|
||||
private async Task<NotificationSummaryViewModel> FetchNotificationsFromDb(string userId)
|
||||
{
|
||||
var resp = new NotificationSummaryViewModel();
|
||||
@@ -90,12 +104,7 @@ namespace BTCPayServer.Services.Notifications
|
||||
{
|
||||
var handler = GetHandler(data.NotificationType);
|
||||
var notification = JsonConvert.DeserializeObject(ZipUtils.Unzip(data.Blob), handler.NotificationBlobType);
|
||||
var obj = new NotificationViewModel
|
||||
{
|
||||
Id = data.Id,
|
||||
Created = data.Created,
|
||||
Seen = data.Seen
|
||||
};
|
||||
var obj = new NotificationViewModel {Id = data.Id, Created = data.Created, Seen = data.Seen};
|
||||
handler.FillViewModel(notification, obj);
|
||||
return obj;
|
||||
}
|
||||
@@ -106,6 +115,7 @@ namespace BTCPayServer.Services.Notifications
|
||||
return h;
|
||||
throw new InvalidOperationException($"No INotificationHandler found for {notificationId}");
|
||||
}
|
||||
|
||||
public INotificationHandler GetHandler(Type blobType)
|
||||
{
|
||||
if (_handlersByBlobType.TryGetValue(blobType, out var h))
|
||||
|
||||
@@ -51,6 +51,10 @@ namespace BTCPayServer.Services.Notifications
|
||||
}
|
||||
await db.SaveChangesAsync();
|
||||
}
|
||||
foreach (string user in users)
|
||||
{
|
||||
_notificationManager.InvalidateNotificationCache(user);
|
||||
}
|
||||
}
|
||||
|
||||
private async Task<string[]> GetUsers(NotificationScope scope)
|
||||
|
||||
@@ -29,6 +29,8 @@
|
||||
Actions
|
||||
</button>
|
||||
<div class="dropdown-menu">
|
||||
<button type="submit" class="dropdown-item" name="command" value="mark-seen"><i class="fa fa-eye"></i> Mark seen</button>
|
||||
<button type="submit" class="dropdown-item" name="command" value="mark-unseen"><i class="fa fa-eye-slash"></i> Mark unseen</button>
|
||||
<button type="submit" class="dropdown-item" name="command" value="delete"><i class="fa fa-trash-o"></i> Delete</button>
|
||||
</div>
|
||||
</span>
|
||||
@@ -39,7 +41,7 @@
|
||||
<table class="table table-sm table-responsive-md">
|
||||
<thead>
|
||||
<tr>
|
||||
<th width="30px"></th>
|
||||
<th width="30px" class="only-for-js"></th>
|
||||
<th width="190px">
|
||||
Date
|
||||
<a href="javascript:switchTimeFormat()">
|
||||
@@ -47,33 +49,33 @@
|
||||
</a>
|
||||
</th>
|
||||
<th>Message</th>
|
||||
<th width="80px"> </th>
|
||||
<th class="text-right">Actions</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
@foreach (var item in Model.Items)
|
||||
{
|
||||
<tr data-guid="@item.Id">
|
||||
<td>
|
||||
<tr data-guid="@item.Id" class="notification-row @(item.Seen ? "seen" : "")">
|
||||
<td class="only-for-js">
|
||||
<input name="selectedItems" type="checkbox" class="selector" value="@item.Id"/>
|
||||
</td>
|
||||
<td onclick="rowseen(this)" class="cursor-pointer @(item.Seen ? "": "font-weight-bold")">
|
||||
<td onclick="toggleRowCheckbox(this)">
|
||||
<span class="switchTimeFormat" data-switch="@item.Created.ToTimeAgo()">
|
||||
@item.Created.ToBrowserDate()
|
||||
</span>
|
||||
</td>
|
||||
<td onclick="rowseen(this)" class="cursor-pointer @(item.Seen ? "": "font-weight-bold")">
|
||||
<td onclick="toggleRowCheckbox(this)">
|
||||
@item.Body
|
||||
</td>
|
||||
<td>
|
||||
<td class="text-right font-weight-normal">
|
||||
@if (!String.IsNullOrEmpty(item.ActionLink))
|
||||
{
|
||||
<a href="@item.ActionLink">Action <i class="fa fa-angle-double-right"></i></a>
|
||||
}
|
||||
else
|
||||
{
|
||||
<span> </span>
|
||||
<a href="@item.ActionLink" class="btn btn-link p-0">Details</a>
|
||||
<span class="d-none d-md-inline-block"> - </span>
|
||||
}
|
||||
<button onclick="return rowseen(this)" class="btn btn-link p-0 btn-toggle-seen" type="submit" name="command" value="flip-individual:@(item.Id)">
|
||||
<span>Mark </span><span class="seen-text"></span>
|
||||
</button>
|
||||
</td>
|
||||
</tr>
|
||||
}
|
||||
@@ -155,13 +157,39 @@
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<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;
|
||||
}
|
||||
tr.seen td {
|
||||
font-weight: normal;
|
||||
}
|
||||
</style>
|
||||
<script type="text/javascript">
|
||||
function rowseen(sender) {
|
||||
var weightClassName = "font-weight-bold";
|
||||
$(sender).parent().children(".cursor-pointer").toggleClass(weightClassName);
|
||||
|
||||
$.post("/Notifications/FlipRead/" + $(sender).parent().data("guid"), function (data) {
|
||||
var row = $(sender).parents(".notification-row").toggleClass("loading");
|
||||
var guid = row.data("guid");
|
||||
var url = "@Url.Action("FlipRead", "Notifications", new {id = "placeholder"})".replace("placeholder", guid);
|
||||
$.post(url, function (data) {
|
||||
row.toggleClass("seen loading");
|
||||
});
|
||||
return false;
|
||||
}
|
||||
|
||||
function toggleRowCheckbox(sender){
|
||||
var input = $(sender).parents(".notification-row").find(".selector");
|
||||
input.prop('checked', !input.prop("checked"));
|
||||
updateSelectors();
|
||||
}
|
||||
|
||||
$(function () {
|
||||
|
||||
@@ -14,7 +14,7 @@
|
||||
<div class="dropdown-menu dropdown-menu-right text-center" aria-labelledby="navbarDropdown">
|
||||
@foreach (var notif in notificationModel.Last5)
|
||||
{
|
||||
<a href="@(notif.ActionLink)" class="dropdown-item border-bottom">
|
||||
<a asp-action="NotificationPassThrough" asp-controller="Notifications" asp-route-id="@notif.Id" class="dropdown-item border-bottom">
|
||||
<div class="text-left" style="width: 200px; white-space:normal;">
|
||||
@notif.Body
|
||||
</div>
|
||||
|
||||
Reference in New Issue
Block a user