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:
Andrew Camilleri
2020-06-23 03:06:02 +02:00
committed by GitHub
parent 12e2b93ac9
commit 13569fe4a2
5 changed files with 164 additions and 69 deletions

View File

@@ -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));

View File

@@ -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))

View File

@@ -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)

View File

@@ -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">&nbsp;</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>&nbsp;</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&nbsp;</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 () {

View File

@@ -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>