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

View File

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

View File

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

View File

@@ -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>
@@ -39,7 +41,7 @@
<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()">
@@ -47,33 +49,33 @@
</a> </a>
</th> </th>
<th>Message</th> <th>Message</th>
<th width="80px">&nbsp;</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
{
<span>&nbsp;</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> </td>
</tr> </tr>
} }
@@ -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 () {

View File

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