Repository: liberapay/liberapay.com

Issue:
Better UI for deleting notifications and marking them as read
Currently when I open the notifications page I see a button at the top saying "Mark all notifications as read", and each notification has an "X" in the top right, that deletes it if clicked. I clicked on a notification's "X" thinking it would mark it as read, and ended up deleting the notification instead. I think the UI for both features could be improved to avoid similar errors and just to make things clearer overall.

Regarding the "mark as read" feature, it should be possible to mark individual notifications as read from the main notifications page. One way to achieve this is to add a separate icon next to the "X" (say, a checkmark).

As for deleting notifications, IMO that should be better protected against mistakes (including misclicks!). For example:
- It should be clearer what the "X" will do, especially since it's a destructive action. This could be done by adding a tooltip.
- The X could me marked red instead of grey (or turn red on hover)
- There could be a confirmation dialog saying that the action is irreversible
- Even better, the action should be reversible, e.g. instead of disappearing outright, a deleted notification could be replaced with a notice explaining what happened and including a link to undo that action.


Discussion:
Is deleting notifications useful in the first place, considering you can mark them as read? Maybe very old notifications could be deleted automatically if they're read.
I've already started working on hiding notifications instead of deleting them immediately, because for some types of notifications we check whether we've sent one like it recently, so by allowing the user to delete a notification we risk them receiving the exact same notification again, which is obviously confusing and problematic.

Resolving patch:
diff --git a/liberapay/models/participant.py b/liberapay/models/participant.py
--- a/liberapay/models/participant.py
+++ b/liberapay/models/participant.py
@@ -1379,17 +1379,20 @@ def notify(self, event, force_email=False, email=True, web=True, idem_key=None,
     def mark_notification_as_read(self, n_id):
         p_id = self.id
         r = self.db.one("""
-            WITH updated AS (
-                UPDATE notifications
-                   SET is_new = false
-                 WHERE participant = %(p_id)s
-                   AND id = %(n_id)s
-                   AND is_new
-                   AND web
-             RETURNING id
-            )
+            UPDATE notifications
+               SET is_new = false
+             WHERE participant = %(p_id)s
+               AND id = %(n_id)s
+               AND is_new
+               AND web;
             UPDATE participants
-               SET pending_notifs = pending_notifs - (SELECT count(*) FROM updated)
+               SET pending_notifs = (
+                       SELECT count(*)
+                         FROM notifications
+                        WHERE participant = %(p_id)s
+                          AND web
+                          AND is_new
+                   )
              WHERE id = %(p_id)s
          RETURNING pending_notifs;
         """, locals())
@@ -1405,17 +1408,20 @@ def mark_notifications_as_read(self, event=None, until=None):
             sql_filter += ' AND id <= %(until)s'
 
         r = self.db.one("""
-            WITH updated AS (
-                UPDATE notifications
-                   SET is_new = false
-                 WHERE participant = %(p_id)s
-                   AND is_new
-                   AND web
-                   {0}
-             RETURNING id
-            )
+            UPDATE notifications
+               SET is_new = false
+             WHERE participant = %(p_id)s
+               AND is_new
+               AND web
+               {0};
             UPDATE participants
-               SET pending_notifs = pending_notifs - (SELECT count(*) FROM updated)
+               SET pending_notifs = (
+                       SELECT count(*)
+                         FROM notifications
+                        WHERE participant = %(p_id)s
+                          AND web
+                          AND is_new
+                   )
              WHERE id = %(p_id)s
          RETURNING pending_notifs;
         """.format(sql_filter), locals())
@@ -1424,22 +1430,34 @@ def mark_notifications_as_read(self, event=None, until=None):
     def remove_notification(self, n_id):
         p_id = self.id
         r = self.db.one("""
-            WITH deleted AS (
-                DELETE FROM notifications
-                 WHERE id = %(n_id)s
-                   AND participant = %(p_id)s
-                   AND web
-             RETURNING is_new
-            )
+            UPDATE notifications
+               SET is_new = false
+                 , hidden_since = current_timestamp
+             WHERE id = %(n_id)s
+               AND participant = %(p_id)s
+               AND web;
             UPDATE participants
-               SET pending_notifs = pending_notifs - (
-                       SELECT count(*) FROM deleted WHERE is_new
+               SET pending_notifs = (
+                       SELECT count(*)
+                         FROM notifications
+                        WHERE participant = %(p_id)s
+                          AND web
+                          AND is_new
                    )
              WHERE id = %(p_id)s
          RETURNING pending_notifs;
         """, locals())
         self.set_attributes(pending_notifs=r)
 
+    def restore_notification(self, n_id):
+        self.db.run("""
+            UPDATE notifications
+               SET hidden_since = NULL
+             WHERE id = %(n_id)s
+               AND participant = %(p_id)s
+               AND hidden_since IS NOT NULL
+        """, dict(n_id=n_id, p_id=self.id))
+
     def fill_notification_context(self, context):
         context.update(aspen_jinja2_renderer.Renderer.global_context)
         context['website'] = website
@@ -1459,10 +1477,12 @@ def fill_notification_context(self, context):
 
     def get_notifs(self, before=None, limit=None):
         return self.db.all("""
-            SELECT id, event, context, is_new, ts
+            SELECT id, event, context, is_new, ts, hidden_since
               FROM notifications
              WHERE participant = %s
                AND web
+               AND ( hidden_since IS NULL OR
+                     hidden_since > (current_timestamp - interval '6 hours') )
                AND coalesce(id < %s, true)
           ORDER BY id DESC
              LIMIT %s
@@ -1477,16 +1497,19 @@ def render_notifications(self, state, notifs=None, before=None, limit=None):
         notifs = notifs or self.get_notifs(before=before, limit=limit)
 
         r = []
-        for id, event, notif_context, is_new, ts in notifs:
+        for id, event, notif_context, is_new, ts, hidden_since in notifs:
             try:
                 notif_context = deserialize(notif_context)
-                context = dict(state)
-                self.fill_notification_context(context)
-                context.update(notif_context)
-                context['notification_ts'] = ts
                 spt = website.emails[event]
-                subject = spt['subject'].render(context).strip()
-                html = spt['text/html'].render(context).strip()
+                if hidden_since:
+                    subject, html = None, None
+                else:
+                    context = dict(state)
+                    self.fill_notification_context(context)
+                    context.update(notif_context)
+                    context['notification_ts'] = ts
+                    subject = spt['subject'].render(context).strip()
+                    html = spt['text/html'].render(context).strip()
                 typ = notif_context.get('type', 'info')
                 r.append(dict(id=id, subject=subject, html=html, type=typ, is_new=is_new, ts=ts))
             except Exception as e:


Test patch:
diff --git a/tests/py/test_notifications.py b/tests/py/test_notifications.py
--- a/tests/py/test_notifications.py
+++ b/tests/py/test_notifications.py
@@ -10,21 +10,37 @@ def test_add_notifications(self):
         alice.notify('1234', email=False)
         assert alice.pending_notifs == 2
 
-    def test_remove_notification(self):
+    def test_remove_and_restore_notification(self):
         alice = self.make_participant('alice')
         bob = self.make_participant('bob')
         alice.notify('abcd', email=False)
-        id = alice.notify('1234', email=False)
+        n_id = alice.notify('1234', email=False)
         alice.notify('bcde', email=False)
 
         # check that bob can't remove alice's notification
-        bob.remove_notification(id)
-        alice = alice.from_id(alice.id)
+        r = self.client.PxST('/alice/notifications', {'remove': str(n_id)}, auth_as=bob)
+        assert r.code == 403
+        alice = alice.refetch()
         assert alice.pending_notifs == 3
 
-        alice.remove_notification(id)
+        # but alice can
+        r = self.client.PxST('/alice/notifications', {'remove': str(n_id)}, auth_as=alice)
+        assert r.code == 302
+        alice = alice.refetch()
         assert alice.pending_notifs == 2
 
+        # check that bob can't restore alice's notification
+        r = self.client.PxST('/alice/notifications', {'restore': str(n_id)}, auth_as=bob)
+        assert r.code == 403
+        notif = self.db.one("SELECT * FROM notifications WHERE id = %s", (n_id,))
+        assert notif.hidden_since is not None
+
+        # but alice can
+        r = self.client.PxST('/alice/notifications', {'restore': str(n_id)}, auth_as=alice)
+        assert r.code == 302
+        notif = self.db.one("SELECT * FROM notifications WHERE id = %s", (n_id,))
+        assert notif.hidden_since is None
+
     def test_render_notifications(self):
         self.client.website.emails['test_event'] = {
             'subject': SimplateLoader(None, """
@@ -92,3 +108,23 @@ def test_marking_notifications_as_read_avoids_race_condition(self):
         """, (alice.id,))
         assert len(notifications) == 1
         assert notifications[0].id == n2
+
+    def test_marking_a_specific_notification_as_read(self):
+        alice = self.make_participant('alice')
+        bob = self.make_participant('bob')
+        alice.notify('a', email=False)
+        n_id = alice.notify('b', email=False)
+        alice.notify('c', email=False)
+
+        # check that bob can't make alice's notification as read
+        form_data = {'mark_as_read': str(n_id)}
+        r = self.client.PxST('/alice/notifications', form_data, auth_as=bob)
+        assert r.code == 403
+        alice = alice.refetch()
+        assert alice.pending_notifs == 3
+
+        # but alice can
+        r = self.client.PxST('/alice/notifications', form_data, auth_as=alice)
+        assert r.code == 302
+        alice = alice.refetch()
+        assert alice.pending_notifs == 2