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'