Bubble [main]

Added a private thanks mechanism

347ffaa393fd2bf1205e06d8ad5d6b50470f9a1d
diff --git a/50_bubble.py b/50_bubble.py
index da3ce55..e6552e8 100644
--- a/50_bubble.py
+++ b/50_bubble.py
@@ -33,6 +33,7 @@ class Bubble:
         self.site_info = cfg.get('info', "Bulletin Boards for Gemini")
         self.site_info_nouser = cfg.get('info.nouser', self.site_info)
         self.likes_enabled = cfg.getboolean('user.likes', True)
+        self.thanks_enabled = cfg.getboolean('user.thanks', True)
         self.user_register = cfg.getboolean('user.register', True)
         self.admin_certpass = cfg.get('admin.certpass', '')
         self.antenna_url = cfg.get('antenna.url', 'gemini://warmedal.se/~antenna/submit')
@@ -114,6 +115,9 @@ Bubble is open source:
                 enabled = enabled and (self.user.flags & User.HIDE_LIKES_FLAG) == 0
             return enabled
 
+        def is_thanks_enabled(self):
+            return self.bubble.thanks_enabled
+
         def is_editable(self, post: Post):
             return self.user.role == User.ADMIN or post.user == self.user.id
 
@@ -475,6 +479,11 @@ In the "Edit text" prompts of the draft composer:
 
 When editing segments in the draft composer, you may sometimes accidentally submit the input and overwrite the contents, losing some text that you meant to keep in there. While Bubble does not currently keep a history of past edited versions, your client may be able to help you. If you navigate backwards, the old contents of the segment may be found in a cached copy of the page.
 
+## Reactions: Likes and Thanks
+
+* "Likes" are a way of giving positive feedback in public. The names of people who have liked a post is visible to everyone. Likes can be disabled per-user or site-wide in the Bubble configuration.
+* "Thanks" are used for showing appreciation privately, in an ephemeral manner. The recipient will see a notification about the thanks. The notification will remain visible in Notification History for a few days, after which all record of it disappears.
+
 ## Follows
 
 You can follow posts, subspaces, and users to be notified of their activity, and to select what is visible in the Followed feed.
@@ -520,7 +529,8 @@ when the administrator assigns at least one moderator to it.
              req.path.startswith(self.path + 'vote/') or \
              req.path.startswith(self.path + 'follow/') or \
              req.path.startswith(self.path + 'unfollow/') or \
-             req.path.startswith(self.path + 'notif/'):
+             req.path.startswith(self.path + 'notif/') or \
+             req.path.startswith(self.path + 'thanks/'):
             return user_actions(session)
 
         elif req.path == self.path + 'dashboard':
diff --git a/feeds.py b/feeds.py
index 57b2149..2fb91b3 100644
--- a/feeds.py
+++ b/feeds.py
@@ -271,11 +271,16 @@ def make_post_page(session, post):
             if session.is_editable(post):
                 page += f'=> /edit/{post.id} ✏️ Edit {kind}\n'
         page += f'=> /comment/{post.id} 💬 Comment\n'
-        if session.is_likes_enabled():
-            if session.user.name not in liked:
-                page += f'=> /like/{post.id} 👍 Like\n'
-            else:
-                page += f'=> /unlike/{post.id} 👎 Undo like\n'
+
+        # Reactions.
+        if session.user.id != post.user:
+            if session.is_likes_enabled():
+                if session.user.name not in liked:
+                    page += f'=> /like/{post.id} 👍 Like\n'
+                else:
+                    page += f'=> /unlike/{post.id} 👎 Undo like\n'
+            if session.is_thanks_enabled():
+                page += f'=> /thanks/{post.id} 🙏 Give thanks\n'
 
         if session.user.id == post.user or session.is_user_mod:
             page += f'=> /edit-tags/{post.id} 🏷️ Add/remove tags\n'
diff --git a/model.py b/model.py
index 09e2228..e79a7ae 100644
--- a/model.py
+++ b/model.py
@@ -53,7 +53,9 @@ class Notification:
     ADDED_AS_MODERATOR           = 0x0400
     REMOVED_AS_MODERATOR         = 0x0800
     COMMENT_IN_FOLLOWED_SUBSPACE = 0x1000
+    THANKS                       = 0x2000
 
+    # Priority order for merging/overriding notifications.
     PRIORITY = {
         COMMENT_IN_FOLLOWED_SUBSPACE: 0,
         POST_IN_FOLLOWED_SUBSPACE: 1,
@@ -103,6 +105,9 @@ class Notification:
         if self.type == Notification.LIKE:
             event = f'liked your {kind}'
             icon = '👍 '
+        elif self.type == Notification.THANKS:
+            event = f'thanked you'
+            icon = '🙏'
         elif self.type == Notification.COMMENT:
             event = f'commented on your {kind}'
             icon = '💬 '
@@ -140,6 +145,8 @@ class Notification:
             if vis_title:
                 if self.type == Notification.MENTION:
                     event += ' in'
+                elif self.type == Notification.THANKS:
+                    event += ' for'
                 elif self.type == Notification.COMMENT_ON_COMMENTED:
                     event += ' about'
                 elif self.type == Notification.COMMENT_IN_FOLLOWED_SUBSPACE:
@@ -467,7 +474,6 @@ class Database:
             is_sent     BOOLEAN DEFAULT FALSE,
             is_hidden   BOOLEAN DEFAULT FALSE,
             ts          TIMESTAMP DEFAULT CURRENT_TIMESTAMP,
-            -- UNIQUE KEY (type, dst, src, post),
             INDEX (dst),
             INDEX (dst, post),
             CONSTRAINT no_self_notif CHECK (src!=dst)
@@ -1612,6 +1618,20 @@ class Database:
         """)
         self.commit()
 
+    def notify_thanks(self, user: User, post: Post):
+        cur = self.conn.cursor()
+        ntype = Notification.THANKS
+        cur.execute(f"""
+            IF (SELECT COUNT(id)=0
+                    FROM notifs
+                    WHERE is_hidden=FALSE AND type={ntype} AND src=? AND dst=? AND post=?)
+            THEN
+                INSERT INTO notifs (type, src, dst, post) VALUES ({ntype}, ?, ?, ?);
+            END IF
+        """, (user.id, post.user, post.id,
+              user.id, post.user, post.id))
+        self.commit()
+
     def notify_new_poll(self, post: Post):
         cur = self.conn.cursor()
         cur.execute(f"""
@@ -1803,6 +1823,7 @@ class Database:
         indexed = {}
         for notif in notifs:
             if not notif.post or notif.type in (Notification.LIKE,
+                                                Notification.THANKS,
                                                 Notification.ISSUE_CLOSED):
                 # Subspace notifications are resolved as-is.
                 resolved.append(notif)
diff --git a/user.py b/user.py
index cdefe9b..0266ee6 100644
--- a/user.py
+++ b/user.py
@@ -23,6 +23,22 @@ def user_actions(session):
         db.modify_likes(session.user, post, add=(action == 'like'))
         return 30, post.page_url()
 
+    elif req.path.startswith(session.path + 'thanks/'):
+        if not user:
+            return 60, 'Login required'
+        found = re.match(r"^thanks/(\d+)$", req.path[len(session.path):])
+        post_id = int(found.group(1))
+        post = db.get_post(id=post_id)
+        if not post:
+            return 51, 'Not found'
+        db.notify_thanks(session.user, post)
+        page = '# 🙏\n\nYou thanked\n'
+        poster = db.get_user(id=post.user)
+        page += f'=> /u/{poster.name} {poster.avatar} {poster.name}\n'
+        page += 'for:\n'
+        page += session.gemini_feed_entry(post, session.context)
+        return page
+
     elif req.path.startswith(session.path + 'vote/'):
         try:
             if not user: