mirror of
https://github.com/aljazceru/btcpayserver.git
synced 2025-12-17 22:14:26 +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;
|
||||||
using System.Collections.Generic;
|
|
||||||
using System.Linq;
|
using System.Linq;
|
||||||
using System.Security.Claims;
|
|
||||||
using System.Text;
|
|
||||||
using System.Threading.Tasks;
|
using System.Threading.Tasks;
|
||||||
using BTCPayServer.Data;
|
using BTCPayServer.Data;
|
||||||
using BTCPayServer.Filters;
|
using BTCPayServer.Filters;
|
||||||
using BTCPayServer.HostedServices;
|
|
||||||
using BTCPayServer.Models.NotificationViewModels;
|
using BTCPayServer.Models.NotificationViewModels;
|
||||||
using BTCPayServer.Security;
|
using BTCPayServer.Security;
|
||||||
using BTCPayServer.Services;
|
using BTCPayServer.Services;
|
||||||
using BTCPayServer.Services.Notifications;
|
using BTCPayServer.Services.Notifications;
|
||||||
using BTCPayServer.Services.Notifications.Blobs;
|
using BTCPayServer.Services.Notifications.Blobs;
|
||||||
using Google;
|
|
||||||
using Microsoft.AspNetCore.Authorization;
|
using Microsoft.AspNetCore.Authorization;
|
||||||
using Microsoft.AspNetCore.Identity;
|
using Microsoft.AspNetCore.Identity;
|
||||||
using Microsoft.AspNetCore.Mvc;
|
using Microsoft.AspNetCore.Mvc;
|
||||||
@@ -21,6 +16,7 @@ namespace BTCPayServer.Controllers
|
|||||||
{
|
{
|
||||||
[BitpayAPIConstraint(false)]
|
[BitpayAPIConstraint(false)]
|
||||||
[Authorize(AuthenticationSchemes = AuthenticationSchemes.Cookie)]
|
[Authorize(AuthenticationSchemes = AuthenticationSchemes.Cookie)]
|
||||||
|
[Route("[controller]/[action]")]
|
||||||
public class NotificationsController : Controller
|
public class NotificationsController : Controller
|
||||||
{
|
{
|
||||||
private readonly BTCPayServerEnvironment _env;
|
private readonly BTCPayServerEnvironment _env;
|
||||||
@@ -58,7 +54,7 @@ namespace BTCPayServer.Controllers
|
|||||||
.Where(a => a.ApplicationUserId == userId)
|
.Where(a => a.ApplicationUserId == userId)
|
||||||
.Select(a => _notificationManager.ToViewModel(a))
|
.Select(a => _notificationManager.ToViewModel(a))
|
||||||
.ToList(),
|
.ToList(),
|
||||||
Total = _db.Notifications.Where(a => a.ApplicationUserId == userId).Count()
|
Total = _db.Notifications.Count(a => a.ApplicationUserId == userId)
|
||||||
};
|
};
|
||||||
|
|
||||||
return View(model);
|
return View(model);
|
||||||
@@ -81,24 +77,81 @@ namespace BTCPayServer.Controllers
|
|||||||
var notif = _db.Notifications.Single(a => a.Id == id && a.ApplicationUserId == userId);
|
var notif = _db.Notifications.Single(a => a.Id == id && a.ApplicationUserId == userId);
|
||||||
notif.Seen = !notif.Seen;
|
notif.Seen = !notif.Seen;
|
||||||
await _db.SaveChangesAsync();
|
await _db.SaveChangesAsync();
|
||||||
|
_notificationManager.InvalidateNotificationCache(userId);
|
||||||
|
return RedirectToAction(nameof(Index));
|
||||||
}
|
}
|
||||||
|
|
||||||
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]
|
[HttpPost]
|
||||||
public async Task<IActionResult> MassAction(string command, string[] selectedItems)
|
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 (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));
|
case "delete":
|
||||||
_db.Notifications.RemoveRange(toRemove);
|
_db.Notifications.RemoveRange(items);
|
||||||
await _db.SaveChangesAsync();
|
|
||||||
|
|
||||||
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));
|
return RedirectToAction(nameof(Index));
|
||||||
|
|||||||
@@ -1,5 +1,6 @@
|
|||||||
using System;
|
using System;
|
||||||
using System.Collections.Generic;
|
using System.Collections.Generic;
|
||||||
|
using System.Globalization;
|
||||||
using System.Linq;
|
using System.Linq;
|
||||||
using System.Security.Claims;
|
using System.Security.Claims;
|
||||||
using System.Text;
|
using System.Text;
|
||||||
@@ -22,7 +23,8 @@ namespace BTCPayServer.Services.Notifications
|
|||||||
private readonly Dictionary<string, INotificationHandler> _handlersByNotificationType;
|
private readonly Dictionary<string, INotificationHandler> _handlersByNotificationType;
|
||||||
private readonly Dictionary<Type, INotificationHandler> _handlersByBlobType;
|
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;
|
_factory = factory;
|
||||||
_userManager = userManager;
|
_userManager = userManager;
|
||||||
@@ -32,19 +34,31 @@ namespace BTCPayServer.Services.Notifications
|
|||||||
}
|
}
|
||||||
|
|
||||||
private const int _cacheExpiryMs = 5000;
|
private const int _cacheExpiryMs = 5000;
|
||||||
|
|
||||||
public async Task<NotificationSummaryViewModel> GetSummaryNotifications(ClaimsPrincipal user)
|
public async Task<NotificationSummaryViewModel> GetSummaryNotifications(ClaimsPrincipal user)
|
||||||
{
|
{
|
||||||
var userId = _userManager.GetUserId(user);
|
var userId = _userManager.GetUserId(user);
|
||||||
|
var cacheKey = GetNotificationsCacheId(userId);
|
||||||
if (_memoryCache.TryGetValue<NotificationSummaryViewModel>(userId, out var obj))
|
if (_memoryCache.TryGetValue<NotificationSummaryViewModel>(cacheKey, out var obj))
|
||||||
return obj;
|
return obj;
|
||||||
|
|
||||||
var resp = await FetchNotificationsFromDb(userId);
|
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;
|
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)
|
private async Task<NotificationSummaryViewModel> FetchNotificationsFromDb(string userId)
|
||||||
{
|
{
|
||||||
var resp = new NotificationSummaryViewModel();
|
var resp = new NotificationSummaryViewModel();
|
||||||
@@ -59,10 +73,10 @@ namespace BTCPayServer.Services.Notifications
|
|||||||
try
|
try
|
||||||
{
|
{
|
||||||
resp.Last5 = (await _db.Notifications
|
resp.Last5 = (await _db.Notifications
|
||||||
.Where(a => a.ApplicationUserId == userId && !a.Seen)
|
.Where(a => a.ApplicationUserId == userId && !a.Seen)
|
||||||
.OrderByDescending(a => a.Created)
|
.OrderByDescending(a => a.Created)
|
||||||
.Take(5)
|
.Take(5)
|
||||||
.ToListAsync())
|
.ToListAsync())
|
||||||
.Select(a => ToViewModel(a))
|
.Select(a => ToViewModel(a))
|
||||||
.ToList();
|
.ToList();
|
||||||
}
|
}
|
||||||
@@ -90,12 +104,7 @@ namespace BTCPayServer.Services.Notifications
|
|||||||
{
|
{
|
||||||
var handler = GetHandler(data.NotificationType);
|
var handler = GetHandler(data.NotificationType);
|
||||||
var notification = JsonConvert.DeserializeObject(ZipUtils.Unzip(data.Blob), handler.NotificationBlobType);
|
var notification = JsonConvert.DeserializeObject(ZipUtils.Unzip(data.Blob), handler.NotificationBlobType);
|
||||||
var obj = new NotificationViewModel
|
var obj = new NotificationViewModel {Id = data.Id, Created = data.Created, Seen = data.Seen};
|
||||||
{
|
|
||||||
Id = data.Id,
|
|
||||||
Created = data.Created,
|
|
||||||
Seen = data.Seen
|
|
||||||
};
|
|
||||||
handler.FillViewModel(notification, obj);
|
handler.FillViewModel(notification, obj);
|
||||||
return obj;
|
return obj;
|
||||||
}
|
}
|
||||||
@@ -106,6 +115,7 @@ namespace BTCPayServer.Services.Notifications
|
|||||||
return h;
|
return h;
|
||||||
throw new InvalidOperationException($"No INotificationHandler found for {notificationId}");
|
throw new InvalidOperationException($"No INotificationHandler found for {notificationId}");
|
||||||
}
|
}
|
||||||
|
|
||||||
public INotificationHandler GetHandler(Type blobType)
|
public INotificationHandler GetHandler(Type blobType)
|
||||||
{
|
{
|
||||||
if (_handlersByBlobType.TryGetValue(blobType, out var h))
|
if (_handlersByBlobType.TryGetValue(blobType, out var h))
|
||||||
|
|||||||
@@ -51,6 +51,10 @@ namespace BTCPayServer.Services.Notifications
|
|||||||
}
|
}
|
||||||
await db.SaveChangesAsync();
|
await db.SaveChangesAsync();
|
||||||
}
|
}
|
||||||
|
foreach (string user in users)
|
||||||
|
{
|
||||||
|
_notificationManager.InvalidateNotificationCache(user);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
private async Task<string[]> GetUsers(NotificationScope scope)
|
private async Task<string[]> GetUsers(NotificationScope scope)
|
||||||
|
|||||||
@@ -9,7 +9,7 @@
|
|||||||
{
|
{
|
||||||
<div class="row">
|
<div class="row">
|
||||||
<div class="col-lg-12 text-center">
|
<div class="col-lg-12 text-center">
|
||||||
<partial name="_StatusMessage" />
|
<partial name="_StatusMessage"/>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
}
|
}
|
||||||
@@ -29,6 +29,8 @@
|
|||||||
Actions
|
Actions
|
||||||
</button>
|
</button>
|
||||||
<div class="dropdown-menu">
|
<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>
|
<button type="submit" class="dropdown-item" name="command" value="delete"><i class="fa fa-trash-o"></i> Delete</button>
|
||||||
</div>
|
</div>
|
||||||
</span>
|
</span>
|
||||||
@@ -38,45 +40,45 @@
|
|||||||
<div class="col-lg-12">
|
<div class="col-lg-12">
|
||||||
<table class="table table-sm table-responsive-md">
|
<table class="table table-sm table-responsive-md">
|
||||||
<thead>
|
<thead>
|
||||||
<tr>
|
<tr>
|
||||||
<th width="30px"></th>
|
<th width="30px" class="only-for-js"></th>
|
||||||
<th width="190px">
|
<th width="190px">
|
||||||
Date
|
Date
|
||||||
<a href="javascript:switchTimeFormat()">
|
<a href="javascript:switchTimeFormat()">
|
||||||
<span class="fa fa-clock-o" title="Switch date format"></span>
|
<span class="fa fa-clock-o" title="Switch date format"></span>
|
||||||
</a>
|
</a>
|
||||||
</th>
|
</th>
|
||||||
<th>Message</th>
|
<th>Message</th>
|
||||||
<th width="80px"> </th>
|
<th class="text-right">Actions</th>
|
||||||
</tr>
|
</tr>
|
||||||
</thead>
|
</thead>
|
||||||
<tbody>
|
<tbody>
|
||||||
@foreach (var item in Model.Items)
|
@foreach (var item in Model.Items)
|
||||||
{
|
{
|
||||||
<tr data-guid="@item.Id">
|
<tr data-guid="@item.Id" class="notification-row @(item.Seen ? "seen" : "")">
|
||||||
<td>
|
<td class="only-for-js">
|
||||||
<input name="selectedItems" type="checkbox" class="selector" value="@item.Id" />
|
<input name="selectedItems" type="checkbox" class="selector" value="@item.Id"/>
|
||||||
</td>
|
</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()">
|
<span class="switchTimeFormat" data-switch="@item.Created.ToTimeAgo()">
|
||||||
@item.Created.ToBrowserDate()
|
@item.Created.ToBrowserDate()
|
||||||
</span>
|
</span>
|
||||||
</td>
|
</td>
|
||||||
<td onclick="rowseen(this)" class="cursor-pointer @(item.Seen ? "": "font-weight-bold")">
|
<td onclick="toggleRowCheckbox(this)">
|
||||||
@item.Body
|
@item.Body
|
||||||
</td>
|
</td>
|
||||||
<td>
|
<td class="text-right font-weight-normal">
|
||||||
@if (!String.IsNullOrEmpty(item.ActionLink))
|
@if (!String.IsNullOrEmpty(item.ActionLink))
|
||||||
{
|
{
|
||||||
<a href="@item.ActionLink">Action <i class="fa fa-angle-double-right"></i></a>
|
<a href="@item.ActionLink" class="btn btn-link p-0">Details</a>
|
||||||
}
|
<span class="d-none d-md-inline-block"> - </span>
|
||||||
else
|
}
|
||||||
{
|
<button onclick="return rowseen(this)" class="btn btn-link p-0 btn-toggle-seen" type="submit" name="command" value="flip-individual:@(item.Id)">
|
||||||
<span> </span>
|
<span>Mark </span><span class="seen-text"></span>
|
||||||
}
|
</button>
|
||||||
</td>
|
</td>
|
||||||
</tr>
|
</tr>
|
||||||
}
|
}
|
||||||
</tbody>
|
</tbody>
|
||||||
</table>
|
</table>
|
||||||
|
|
||||||
@@ -155,13 +157,39 @@
|
|||||||
</div>
|
</div>
|
||||||
</section>
|
</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">
|
<script type="text/javascript">
|
||||||
function rowseen(sender) {
|
function rowseen(sender) {
|
||||||
var weightClassName = "font-weight-bold";
|
var row = $(sender).parents(".notification-row").toggleClass("loading");
|
||||||
$(sender).parent().children(".cursor-pointer").toggleClass(weightClassName);
|
var guid = row.data("guid");
|
||||||
|
var url = "@Url.Action("FlipRead", "Notifications", new {id = "placeholder"})".replace("placeholder", guid);
|
||||||
$.post("/Notifications/FlipRead/" + $(sender).parent().data("guid"), function (data) {
|
$.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 () {
|
$(function () {
|
||||||
|
|||||||
@@ -14,7 +14,7 @@
|
|||||||
<div class="dropdown-menu dropdown-menu-right text-center" aria-labelledby="navbarDropdown">
|
<div class="dropdown-menu dropdown-menu-right text-center" aria-labelledby="navbarDropdown">
|
||||||
@foreach (var notif in notificationModel.Last5)
|
@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;">
|
<div class="text-left" style="width: 200px; white-space:normal;">
|
||||||
@notif.Body
|
@notif.Body
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
Reference in New Issue
Block a user