Bubble [main]

Comment pages: partial thread view, give thanks

2f61940adb60faded24c12edf8b7a0d15fb804a3
diff --git a/db-migrate.sql b/db-migrate.sql
index 391761b..45a6508 100644
--- a/db-migrate.sql
+++ b/db-migrate.sql
@@ -22,3 +22,4 @@ ALTER TABLE posts ADD INDEX (issueid);
 -- Migration from v4 to v5 --
 ALTER TABLE users ADD COLUMN timezone VARCHAR(40) DEFAULT 'UTC';
 ALTER TABLE users ADD COLUMN recovery VARCHAR(1000) DEFAULT '';
+ALTER TABLE notifs ADD COLUMN comment INT;
diff --git a/feeds.py b/feeds.py
index 43715d0..864e70f 100644
--- a/feeds.py
+++ b/feeds.py
@@ -184,8 +184,11 @@ def make_post_page_or_configure_feed(session):
             post = db.get_post(id=int(arg), draft=False)
         if not post:
             return 51, 'Not found'
+
+        if post.parent != 0:
+            return make_post_page(session, post)
+
         if post.subspace != session.context.id:
-            #return 51, 'Not found'
             # Redirect to the correct subspace.
             post_sub = db.get_subspace(id=post.subspace)
             if post_sub.flags & Subspace.ISSUE_TRACKER:
@@ -193,11 +196,6 @@ def make_post_page_or_configure_feed(session):
             else:
                 return 31, f'{session.path}{post_sub.title()}/{post.id}'
 
-        if post.parent != 0:
-            # Comments cannot be viewed individually.
-            # TODO: Make this possible.
-            return 30, db.get_post(id=post.parent).page_url()
-
         if arg2 == '/antenna':
             # Special viewing mode for Antenna submissions, with the bare minimum.
             page = f'# {session.feed_title()}\n'
@@ -211,9 +209,7 @@ def make_post_page_or_configure_feed(session):
             db.get_notifications(session.user, post_id=post.id, clear=True)
             return 30, post.page_url()
 
-        page = make_post_page(session, post)
-
-        return page
+        return make_post_page(session, post)
 
     return None
 
@@ -221,8 +217,50 @@ def make_post_page_or_configure_feed(session):
 def make_post_page(session, post):
     db = session.db
     user = session.user
+    is_comment_page = post.parent != 0
+    display_order_desc = session.user and \
+        session.user.sort_cmt == User.SORT_COMMENT_NEWEST
+    page = ''
+
+    if is_comment_page:
+        # Switch to the parent post, but display it in preview mode.
+        focused_cmt = post
+        last_age = focused_cmt.age()
+        post = db.get_post(id=post.parent)
+        page += f'=> {post.page_url()} Comment on: "{post.title if post.title else shorten_text(strip_links(clean_title(post.summary)), 60)}" in {("u/" if post.sub_owner else "s/") + post.sub_name}\n'
+        page += f'=> /u/{focused_cmt.poster_name} {focused_cmt.poster_avatar} {focused_cmt.poster_name}\n'
+        page += f'{last_age}\n\n'
+        page += session.render_post(focused_cmt)
+
+        # Comment actions.
+        actions = []
+        if session.is_editable(focused_cmt):
+            actions.append(f'=> /edit/{focused_cmt.id} โœ๏ธ Edit comment\n')
+        if session.user and not session.is_context_locked and display_order_desc:
+            actions.append(f'=> /comment/{post.id} ๐Ÿ’ฌ Comment\n')
+        if session.is_thanks_enabled() and focused_cmt.user != user.id:
+            actions.append(f'=> /thanks/{focused_cmt.id} ๐Ÿ™ Give thanks\n')
+        if session.is_deletable(focused_cmt) and not session.is_editable(focused_cmt):
+            actions.append(f'=> /edit/{focused_cmt.id}/delete/{session.get_token()} โŒ Delete comment\n')
+        actions.append(session.dashboard_link())
+
+        if actions:
+            page += '\n' + ''.join(actions)
+
+        op_section = '\n## Original Post\n\n' + session.feed_entry(post)
+        if not display_order_desc:
+            page += op_section
+
+    else:
+        page += session.render_post(post)
 
-    page = session.render_post(post)
+    # Poll options/results.
+    poll = session.render_poll(post, show_results=not session.user)
+    if poll:
+        # Ensure separation.
+        if len(page) and not page.endswith('\n\n'):
+            page += '\n'
+        page += poll
 
     commits = []
     incoming_xrefs = []
@@ -250,130 +288,125 @@ def make_post_page(session, post):
     else:
         repo = None
 
-    # Poll.
-    poll = session.render_poll(post, show_results=not session.user)
-    if poll:
-        # Ensure separation.
-        if len(page) and not page.endswith('\n\n'):
+    if not is_comment_page:
+        # Post metadata.
+        if len(page):
             page += '\n'
-        page += poll
-
-    # Metadata.
-    if len(page):
+        if post.tags:
+            page += '### ' + post.tags + '\n'
+        poster_link = f'=> /u/{post.poster_name} {post.poster_avatar} {post.poster_name}\n'
+        if session.is_context_tracker:
+            page += f'=> /{session.context.title()} ๐Ÿž Issue #{post.issueid} in {session.context.title()}\n'
+        elif not session.c_user:
+            page += f'=> /{session.context.title()} Posted in: {session.context.title()}\n'
+        page += poster_link
+        last_age = post.age()
+        page += f'{last_age}'
+        if session.is_likes_enabled():
+            liked = []
+            if post.num_likes:
+                liked = db.get_likes(post)
+                page += ' ยท ๐Ÿ‘ ' + ', '.join(liked)
+        if session.is_reactions_enabled():
+            reactions = db.get_reactions(post)
+            listed = []
+            for r in reactions:
+                listed.append(f'{reactions[r]} {r}')
+            if listed:
+                page += ' ยท ' + ' '.join(listed)
         page += '\n'
-    if post.tags:
-        page += '### ' + post.tags + '\n'
-    poster_link = f'=> /u/{post.poster_name} {post.poster_avatar} {post.poster_name}\n'
-    if session.is_context_tracker:
-        page += f'=> /{session.context.title()} ๐Ÿž Issue #{post.issueid} in {session.context.title()}\n'
-    elif not session.c_user:
-        page += f'=> /{session.context.title()} Posted in: {session.context.title()}\n'
-    page += poster_link
-    last_age = post.age()
-    page += f'{last_age}'
-    if session.is_likes_enabled():
-        liked = []
-        if post.num_likes:
-            liked = db.get_likes(post)
-            page += ' ยท ๐Ÿ‘ ' + ', '.join(liked)
-    if session.is_reactions_enabled():
-        reactions = db.get_reactions(post)
-        listed = []
-        for r in reactions:
-            listed.append(f'{reactions[r]} {r}')
-        if listed:
-            page += ' ยท ' + ' '.join(listed)
-    page += '\n'
 
-    # Post actions.
-    kind = 'issue' if session.is_context_tracker else 'post'
-    if session.user and not session.is_context_locked:
-        page += '\n## Actions\n'
-        if session.user.id == post.user or session.is_user_mod:
+        # Post actions.
+        kind = 'issue' if session.is_context_tracker else 'post'
+        if session.user and not session.is_context_locked:
+            page += '\n## Actions\n'
             if session.is_editable(post):
                 page += f'=> /edit/{post.id} โœ๏ธ Edit {kind}\n'
-        page += f'=> /comment/{post.id} ๐Ÿ’ฌ Comment\n'
+            page += f'=> /comment/{post.id} ๐Ÿ’ฌ Comment\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_reactions_enabled():
-                reaction = db.get_user_reaction(post, session.user.id)
-                if reaction:
-                    page += f'=> /react/{post.id} Change reaction: {reaction}\n'
-                else:
-                    page += f'=> /react/{post.id} {session.bubble.user_reactions[0]} React\n'
-            if session.is_thanks_enabled():
-                page += f'=> /thanks/{post.id} ๐Ÿ™ Give thanks\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_reactions_enabled():
+                    reaction = db.get_user_reaction(post, session.user.id)
+                    if reaction:
+                        page += f'=> /react/{post.id} Change reaction: {reaction}\n'
+                    else:
+                        page += f'=> /react/{post.id} {session.bubble.user_reactions[0]} React\n'
+                if session.is_thanks_enabled():
+                    page += f'=> /thanks/{post.id} ๐Ÿ™ Give thanks\n'
 
-        if session.user.id != post.user:
-            if (FOLLOW_POST, post.id) in session.user_follows:
-                page += f'=> /unfollow/post/{post.id} โž– Unfollow {kind}\n'
+            if session.user.id != post.user:
+                if (FOLLOW_POST, post.id) in session.user_follows:
+                    page += f'=> /unfollow/post/{post.id} โž– Unfollow {kind}\n'
+                else:
+                    if (MUTE_POST, post.id) in session.user_mutes:
+                        page += f'=> /unmute/post/{post.id} ๐Ÿ”ˆ Unmute {kind}\n'
+                    else:
+                        page += f'=> /follow/post/{post.id} โž• Follow {kind}\n'
+                        page += f'=> /mute/post/{post.id} ๐Ÿ”‡ Mute {kind}\n'
             else:
+                # Own posts can be muted.
                 if (MUTE_POST, post.id) in session.user_mutes:
                     page += f'=> /unmute/post/{post.id} ๐Ÿ”ˆ Unmute {kind}\n'
                 else:
-                    page += f'=> /follow/post/{post.id} โž• Follow {kind}\n'
                     page += f'=> /mute/post/{post.id} ๐Ÿ”‡ Mute {kind}\n'
-        else:
-            # Own posts can be muted.
-            if (MUTE_POST, post.id) in session.user_mutes:
-                page += f'=> /unmute/post/{post.id} ๐Ÿ”ˆ Unmute {kind}\n'
-            else:
-                page += f'=> /mute/post/{post.id} ๐Ÿ”‡ Mute {kind}\n'
 
-        # Moderator actions on a post.
-        mod_actions = []
-        if session.user.id == post.user or session.is_user_mod:
-            if session.is_context_tracker:
-                if 'โœ”๏ธŽ' in post.tags:
-                    mod_actions.append(f'=> /edit-tags/{post.id}/open ๐Ÿž Reopen issue\n')
-                else:
-                    mod_actions.append(f'=> /edit-tags/{post.id}/close โœ”๏ธŽ Mark as closed\n')
-            mod_actions.append(f'=> /edit-tags/{post.id} ๐Ÿท๏ธ Add/remove tags\n')
-        if session.is_title_editable(post) and not session.is_editable(post):
-            mod_actions.append(f'=> /edit/{post.id}/mod-title โœ๏ธ Edit {kind} title\n')
-        if session.is_movable(post):
-            mod_actions.append(f'=> /edit/{post.id}/move/{session.get_token()} Move to subspace\n')
-        if session.is_deletable(post) and not session.is_editable(post):
-            mod_actions.append(f'=> /edit/{post.id}/delete/{session.get_token()} โŒ Delete {kind}\n')
-        if session.user.id == post.user and post.sub_owner == post.user:
-            antenna_feed = f"gemini://{session.bubble.hostname}{session.path}u/{session.user.name}/{post.id}/antenna"
-            mod_actions.append(f'=> {session.bubble.antenna_url}?{urlparse.quote(antenna_feed)} Submit post to ๐Ÿ“ก Antenna\n')
-
-        if mod_actions:
-            page += '\n' + ''.join(mod_actions)
-
-        page += '\n' + session.dashboard_link()
-
-        notifs = db.get_notifications(user=user, post_id=post.id)
-        if notifs:
-            page += f'{len(notifs)} notification{plural_s(len(notifs))} on this page:\n'
-            for notif in notifs:
-                link, label = notif.entry(with_title=False)
-                page += f'=> {link} {label}\n'
-            if len(notifs) > 1:
-                page += f'=> {post.page_url()}/clear-notif/{session.get_token()} ๐Ÿงน Clear\n'
+            # Moderator actions on a post.
+            mod_actions = []
+            if session.user.id == post.user or session.is_user_mod:
+                if session.is_context_tracker:
+                    if 'โœ”๏ธŽ' in post.tags:
+                        mod_actions.append(f'=> /edit-tags/{post.id}/open ๐Ÿž Reopen issue\n')
+                    else:
+                        mod_actions.append(f'=> /edit-tags/{post.id}/close โœ”๏ธŽ Mark as closed\n')
+                mod_actions.append(f'=> /edit-tags/{post.id} ๐Ÿท๏ธ Add/remove tags\n')
+            if session.is_title_editable(post) and not session.is_editable(post):
+                mod_actions.append(f'=> /edit/{post.id}/mod-title โœ๏ธ Edit {kind} title\n')
+            if session.is_movable(post):
+                mod_actions.append(f'=> /edit/{post.id}/move/{session.get_token()} Move to subspace\n')
+            if session.is_deletable(post) and not session.is_editable(post):
+                mod_actions.append(f'=> /edit/{post.id}/delete/{session.get_token()} โŒ Delete {kind}\n')
+            if session.user.id == post.user and post.sub_owner == post.user:
+                antenna_feed = f"gemini://{session.bubble.hostname}{session.path}u/{session.user.name}/{post.id}/antenna"
+                mod_actions.append(f'=> {session.bubble.antenna_url}?{urlparse.quote(antenna_feed)} Submit post to ๐Ÿ“ก Antenna\n')
+
+            if mod_actions:
+                page += '\n' + ''.join(mod_actions)
+
+            page += '\n' + session.dashboard_link()
+
+            notifs = db.get_notifications(user=user, post_id=post.id)
+            if notifs:
+                page += f'{len(notifs)} notification{plural_s(len(notifs))} on this page:\n'
+                for notif in notifs:
+                    link, label = notif.entry(with_title=False)
+                    page += f'=> {link} {label}\n'
+                if len(notifs) > 1:
+                    page += f'=> {post.page_url()}/clear-notif/{session.get_token()} ๐Ÿงน Clear\n'
 
     # Comments, repository commits, and issue cross-references.
-    display_order_desc = session.user and \
-        session.user.sort_cmt == User.SORT_COMMENT_NEWEST
     comments = db.get_posts(parent=post.id,
                             draft=False,
                             sort_descending=False,
                             muted_by_user_id=(user.id if user else 0),
                             limit=None)
+
+    if is_comment_page:
+        # Omit comments older than the focused one.
+        comments = list(filter(lambda p: p.ts_created > focused_cmt.ts_created, comments))
+
     n = len(comments)
     if n > 0 or commits or incoming_xrefs:
         if n > 1:
             dir_icon = ' โ†‘' if display_order_desc else ' โ†“'
         else:
             dir_icon = ''
-        page += f'\n## {n} Comment{plural_s(n)}{dir_icon}'
+        page += f'\n## {n} {"Later " if is_comment_page else ""}Comment{plural_s(n)}{dir_icon}\n'
 
         if commits or incoming_xrefs:
             # Combine commits and commits into one list.
@@ -395,7 +428,7 @@ def make_post_page(session, post):
                 rendered_comments.append(cmt.incoming_entry())
                 continue
 
-            src = f'=> /u/{cmt.poster_name} {cmt.poster_avatar} {cmt.poster_name}\n'
+            src = f'=> /u/{cmt.poster_name}/{cmt.id} {cmt.poster_avatar} {cmt.poster_name}\n'
             comment_body = session.render_post(cmt)
             src += comment_body
 
@@ -435,10 +468,13 @@ def make_post_page(session, post):
         for rendered in rendered_comments:
             page += '\n' + rendered
 
+    if is_comment_page and display_order_desc:
+        page += op_section
+
     # Show the Comment action at the appropriate place wrt reading direction.
     if session.user and not session.is_context_locked and \
             len(comments) >= 1 and not display_order_desc:
-        page += f'\n=> /comment/{post.id} ๐Ÿ’ฌ Comment\n'
+        page += f'\n=> /comment/{post.id} ๐Ÿ’ฌ Add comment\n'
 
     return page
 
diff --git a/model.py b/model.py
index c45808d..6c41874 100644
--- a/model.py
+++ b/model.py
@@ -76,7 +76,7 @@ class Notification:
         MENTION: 11
     }
 
-    def __init__(self, id, type, dst, src, post, subspace, is_sent, ts,
+    def __init__(self, id, type, dst, src, post, subspace, comment, is_sent, ts,
                  src_name=None, post_title=None, post_issueid=None, post_summary=None,
                  post_subname=None, post_subowner=None, subname=None, reaction=None):
         self.id = id
@@ -85,6 +85,7 @@ class Notification:
         self.src = src
         self.post = post
         self.subspace = subspace
+        self.comment = comment
         self.is_sent = is_sent
         self.ts = ts
         self.src_name = src_name
@@ -503,6 +504,7 @@ class Database:
             src         INT,
             post        INT,
             subspace    INT,
+            comment     INT,    -- if not NULL, notification is about a comment (can be linked to)
             is_sent     BOOLEAN DEFAULT FALSE,
             is_hidden   BOOLEAN DEFAULT FALSE,
             ts          TIMESTAMP DEFAULT CURRENT_TIMESTAMP,
@@ -1233,18 +1235,19 @@ class Database:
 
                 self.notify_followers(user, parent_post.id,
                                       Notification.COMMENT_IN_FOLLOWED_SUBSPACE,
-                                      FOLLOW_SUBSPACE, parent_post.subspace)
+                                      FOLLOW_SUBSPACE, parent_post.subspace, comment_id=post.id)
                 self.notify_followers(user, parent_post.id,
                                       Notification.COMMENT_BY_FOLLOWED_USER,
-                                      FOLLOW_USER, user.id)
+                                      FOLLOW_USER, user.id, comment_id=post.id)
                 self.notify_followers(user, parent_post.id,
                                       Notification.COMMENT_ON_FOLLOWED_POST,
-                                      FOLLOW_POST, parent_post.id)
+                                      FOLLOW_POST, parent_post.id, comment_id=post.id)
 
                 if parent_post.user != user.id:
                     # Notify post author of a new comment.
-                    cur.execute("INSERT IGNORE INTO notifs (type, dst, src, post) VALUES (?, ?, ?, ?)",
-                        (Notification.COMMENT, parent_post.user, user.id, parent_post.id))
+                    cur.execute("INSERT IGNORE INTO notifs (type, dst, src, post, comment) "
+                                "VALUES (?, ?, ?, ?, ?)",
+                                (Notification.COMMENT, parent_post.user, user.id, parent_post.id, post.id))
 
                 self.commit()
 
@@ -1746,9 +1749,9 @@ class Database:
             notif_post_id = post.parent if post.parent else post.id
             where_names_cond = f"name IN ({('?,' * len(names))[:-1]})"
             cur.execute(f"""
-                INSERT IGNORE INTO notifs (type, dst, src, post)
+                INSERT IGNORE INTO notifs (type, dst, src, post, comment)
                 SELECT
-                    {Notification.MENTION}, id, {post.user}, {notif_post_id}
+                    {Notification.MENTION}, id, {post.user}, {notif_post_id}, {post.id if post.parent else "NULL"}
                 FROM users
                 WHERE id!={post.user} AND {where_names_cond}
             """, names)
@@ -1768,24 +1771,27 @@ class Database:
         for (i,) in cur: uids.append(i)
         for uid in uids:
             cur.execute("""
-                INSERT IGNORE INTO notifs (type, dst, src, post)
-                VALUES (?, ?, ?, ?)
-            """, (Notification.COMMENT_ON_COMMENTED, uid, new_comment.user, new_comment.parent))
+                INSERT IGNORE INTO notifs (type, dst, src, post, comment)
+                VALUES (?, ?, ?, ?, ?)
+            """, (Notification.COMMENT_ON_COMMENTED, uid, new_comment.user, new_comment.parent,
+                  new_comment.id))
         if uids:
             self.commit()
 
-    def notify_followers(self, actor: User, post_id, notif_type, follow_type, target_id):
+    def notify_followers(self, actor: User, post_id, notif_type, follow_type, target_id,
+                         comment_id=None):
         cur = self.conn.cursor()
         cur.execute(f"""
-            INSERT IGNORE INTO notifs (type, dst, src, post)
+            INSERT IGNORE INTO notifs (type, dst, src, post, comment)
             SELECT
                 {notif_type},
                 user,
                 {actor.id},
-                {post_id}
+                {post_id},
+                ?
             FROM follow
             WHERE type={follow_type} AND target={target_id}
-        """)
+        """, (comment_id,))
         self.commit()
 
     def notify_thanks(self, user: User, post: Post):
@@ -1980,8 +1986,12 @@ class Database:
         cur = self.conn.cursor()
         cur.execute(f"""
             SELECT
-                n.id, n.type, n.dst, n.src, n.post, n.subspace, n.is_sent, UNIX_TIMESTAMP(n.ts),
-                u.name, p.title, p.issueid, p.summary, s.name, s.owner, s2.name, r.reaction
+                n.id, n.type, n.dst, n.src, n.post, n.subspace, n.comment, n.is_sent, UNIX_TIMESTAMP(n.ts),
+                u.name,
+                p.title, p.issueid, p.summary,
+                s.name, s.owner,
+                s2.name,
+                r.reaction
             FROM notifs n
                 JOIN users u ON src=u.id
                 LEFT JOIN posts p ON post=p.id
@@ -1996,9 +2006,9 @@ class Database:
             WHERE {' AND '.join(cond)}
         """, tuple(values))
         notifs = []
-        for (id, type, dst, src, post, subspace, is_sent, ts, src_name, post_title, post_issueid,
+        for (id, type, dst, src, post, subspace, comment, is_sent, ts, src_name, post_title, post_issueid,
              post_summary, post_subname, post_subowner, subname, reaction) in cur:
-            notifs.append(Notification(id, type, dst, src, post, subspace, is_sent, ts,
+            notifs.append(Notification(id, type, dst, src, post, subspace, comment, is_sent, ts,
                                        src_name, post_title, post_issueid, post_summary,
                                        post_subname, post_subowner, subname, reaction))
 
diff --git a/user.py b/user.py
index 29490aa..fdea442 100644
--- a/user.py
+++ b/user.py
@@ -174,12 +174,17 @@ def user_actions(session):
         notif = db.get_notification(session.user, notif_id, clear=True)
         if not notif:
             return 30, '/dashboard'
+        if notif.comment:
+            cmt = db.get_post(id=notif.comment)
+            if not cmt:
+                return 51, 'Not found'
+            return 30, cmt.page_url()
         if notif.post:
             post = db.get_post(id=notif.post)
             if not post:
                 return 51, 'Not found'
             return 30, post.page_url()
-        elif notif.subspace:
+        if notif.subspace:
             subs = db.get_subspace(id=notif.subspace)
             if not subs:
                 return 51, 'Not found'