post replies - more elegant handling of long conversations

This commit is contained in:
rimu 2023-10-16 21:38:36 +13:00
parent ef275f4fbf
commit 4271ee3bca
10 changed files with 276 additions and 78 deletions

View file

@ -5,10 +5,11 @@ from flask_login import login_user, logout_user, current_user, login_required
from flask_babel import _ from flask_babel import _
from sqlalchemy import or_ from sqlalchemy import or_
from app import db from app import db, constants
from app.activitypub.signature import RsaKeys, HttpSignature from app.activitypub.signature import RsaKeys, HttpSignature
from app.community.forms import SearchRemoteCommunity, AddLocalCommunity, CreatePost, NewReplyForm from app.community.forms import SearchRemoteCommunity, AddLocalCommunity, CreatePost, NewReplyForm
from app.community.util import search_for_community, community_url_exists, actor_to_community, post_replies from app.community.util import search_for_community, community_url_exists, actor_to_community, post_replies, \
get_comment_branch
from app.constants import SUBSCRIPTION_MEMBER, SUBSCRIPTION_OWNER, POST_TYPE_LINK, POST_TYPE_ARTICLE, POST_TYPE_IMAGE from app.constants import SUBSCRIPTION_MEMBER, SUBSCRIPTION_OWNER, POST_TYPE_LINK, POST_TYPE_ARTICLE, POST_TYPE_IMAGE
from app.models import User, Community, CommunityMember, CommunityJoinRequest, CommunityBan, Post, PostReply, \ from app.models import User, Community, CommunityMember, CommunityJoinRequest, CommunityBan, Post, PostReply, \
PostReplyVote PostReplyVote
@ -81,7 +82,7 @@ def show_community(community: Community):
mod_user_ids = [mod.user_id for mod in mods] mod_user_ids = [mod.user_id for mod in mods]
mod_list = User.query.filter(User.id.in_(mod_user_ids)).all() mod_list = User.query.filter(User.id.in_(mod_user_ids)).all()
if current_user.ignore_bots: if current_user.is_anonymous or current_user.ignore_bots:
posts = community.posts.filter(Post.from_bot == False).all() posts = community.posts.filter(Post.from_bot == False).all()
else: else:
posts = community.posts posts = community.posts
@ -244,7 +245,7 @@ def show_post(post_id: int):
else: else:
replies = post_replies(post.id, 'top') replies = post_replies(post.id, 'top')
return render_template('community/post.html', title=post.title, post=post, is_moderator=is_moderator, return render_template('community/post.html', title=post.title, post=post, is_moderator=is_moderator,
canonical=post.ap_id, form=form, replies=replies) canonical=post.ap_id, form=form, replies=replies, THREAD_CUTOFF_DEPTH=constants.THREAD_CUTOFF_DEPTH)
@bp.route('/comment/<int:comment_id>/<vote_direction>', methods=['POST']) @bp.route('/comment/<int:comment_id>/<vote_direction>', methods=['POST'])
@ -295,8 +296,15 @@ def comment_vote(comment_id, vote_direction):
@bp.route('/post/<int:post_id>/comment/<int:comment_id>') @bp.route('/post/<int:post_id>/comment/<int:comment_id>')
def show_comment(post_id, comment_id): def continue_discussion(post_id, comment_id):
... post = Post.query.get_or_404(post_id)
comment = PostReply.query.get_or_404(comment_id)
mods = post.community.moderators()
is_moderator = current_user.is_authenticated and any(mod.user_id == current_user.id for mod in mods)
replies = get_comment_branch(post.id, comment.id, 'top')
return render_template('community/continue_discussion.html', title=_('Discussing %(title)s', title=post.title), post=post,
is_moderator=is_moderator, comment=comment, replies=replies)
@bp.route('/post/<int:post_id>/comment/<int:comment_id>/reply', methods=['GET', 'POST']) @bp.route('/post/<int:post_id>/comment/<int:comment_id>/reply', methods=['GET', 'POST'])
@ -321,7 +329,10 @@ def add_reply(post_id: int, comment_id: int):
flash('Your comment has been added.') flash('Your comment has been added.')
# todo: flush cache # todo: flush cache
# todo: federation # todo: federation
return redirect(url_for('community.show_post', post_id=post_id, _anchor=f'comment_{reply.id}')) if reply.depth <= constants.THREAD_CUTOFF_DEPTH:
return redirect(url_for('community.show_post', post_id=post_id, _anchor=f'comment_{reply.parent_id}'))
else:
return redirect(url_for('community.continue_discussion', post_id=post_id, comment_id=reply.parent_id))
else: else:
return render_template('community/add_reply.html', title=_('Discussing %(title)s', title=post.title), post=post, return render_template('community/add_reply.html', title=_('Discussing %(title)s', title=post.title), post=post,
is_moderator=is_moderator, form=form, comment=comment) is_moderator=is_moderator, form=form, comment=comment)

View file

@ -101,3 +101,28 @@ def post_replies(post_id: int, sort_by: str, show_first: int = 0) -> List[PostRe
parent_comment['replies'].append(comments_dict[comment.id]) parent_comment['replies'].append(comments_dict[comment.id])
return [comment for comment in comments_dict.values() if comment['comment'].parent_id is None] return [comment for comment in comments_dict.values() if comment['comment'].parent_id is None]
def get_comment_branch(post_id: int, comment_id: int, sort_by: str) -> List[PostReply]:
# Fetch the specified parent comment and its replies
parent_comment = PostReply.query.get(comment_id)
if parent_comment is None:
return []
comments = PostReply.query.filter(PostReply.post_id == post_id)
if sort_by == 'hot':
comments = comments.order_by(desc(PostReply.ranking))
elif sort_by == 'top':
comments = comments.order_by(desc(PostReply.score))
elif sort_by == 'new':
comments = comments.order_by(desc(PostReply.posted_at))
comments_dict = {comment.id: {'comment': comment, 'replies': []} for comment in comments.all()}
for comment in comments:
if comment.parent_id is not None:
parent_comment = comments_dict.get(comment.parent_id)
if parent_comment:
parent_comment['replies'].append(comments_dict[comment.id])
return [comment for comment in comments_dict.values() if comment['comment'].id == comment_id]

View file

@ -14,3 +14,4 @@ SUBSCRIPTION_MEMBER = 1
SUBSCRIPTION_NONMEMBER = 0 SUBSCRIPTION_NONMEMBER = 0
SUBSCRIPTION_BANNED = -1 SUBSCRIPTION_BANNED = -1
THREAD_CUTOFF_DEPTH = 4

View file

@ -18,3 +18,7 @@
@media (max-width: 576px) { @content ; } @media (max-width: 576px) { @content ; }
} }
} }
.pl-0 {
padding-left: 0;
}

View file

@ -1,5 +1,9 @@
/* This file contains SCSS used for creating the general structure of pages. Selectors should be things like body, h1, /* This file contains SCSS used for creating the general structure of pages. Selectors should be things like body, h1,
nav, etc which are used site-wide */ nav, etc which are used site-wide */
.pl-0 {
padding-left: 0;
}
/* for more info about the feather font used for icons see https://at-ui.github.io/feather-font/ */ /* for more info about the feather font used for icons see https://at-ui.github.io/feather-font/ */
/* use https://fontdrop.info/ to get the unicode values of the featuer.ttf file */ /* use https://fontdrop.info/ to get the unicode values of the featuer.ttf file */
@font-face { @font-face {
@ -362,9 +366,22 @@ fieldset legend {
text-decoration: none; text-decoration: none;
} }
.comments > .comment {
margin-left: 0;
}
#replies {
scroll-margin-top: 5em;
}
.post_replies > .col {
padding-right: 5px;
}
.comment { .comment {
clear: both; clear: both;
margin-bottom: 20px; margin-bottom: 20px;
margin-left: 20px;
} }
.comment .limit_height { .comment .limit_height {
position: relative; position: relative;
@ -439,6 +456,10 @@ fieldset legend {
.comment .comment_actions a { .comment .comment_actions a {
text-decoration: none; text-decoration: none;
} }
.comment .replies {
margin-top: 15px;
border-left: solid 1px #ddd;
}
.add_reply .form-control-label { .add_reply .form-control-label {
display: none; display: none;

View file

@ -136,9 +136,22 @@ nav, etc which are used site-wide */
} }
} }
.comments > .comment {
margin-left: 0;
}
#replies {
scroll-margin-top: 5em;
}
.post_replies > .col {
padding-right: 5px;
}
.comment { .comment {
clear: both; clear: both;
margin-bottom: 20px; margin-bottom: 20px;
margin-left: 20px;
.limit_height { .limit_height {
position: relative; position: relative;
@ -231,6 +244,11 @@ nav, etc which are used site-wide */
text-decoration: none; text-decoration: none;
} }
} }
.replies {
margin-top: 15px;
border-left: solid 1px $light-grey;
}
} }
.add_reply { .add_reply {

View file

@ -1,4 +1,8 @@
/* */ /* */
.pl-0 {
padding-left: 0;
}
/* for more info about the feather font used for icons see https://at-ui.github.io/feather-font/ */ /* for more info about the feather font used for icons see https://at-ui.github.io/feather-font/ */
/* use https://fontdrop.info/ to get the unicode values of the featuer.ttf file */ /* use https://fontdrop.info/ to get the unicode values of the featuer.ttf file */
@font-face { @font-face {

View file

@ -0,0 +1,62 @@
<div class="row">
{% if post.image_id %}
<div class="col-8">
<nav aria-label="breadcrumb" id="breadcrumb_nav" title="Navigation">
<ol class="breadcrumb">
<li class="breadcrumb-item"><a href="/">{{ _('Home') }}</a></li>
<li class="breadcrumb-item"><a href="/communities">{{ _('Communities') }}</a></li>
<li class="breadcrumb-item"><a href="/c/{{ post.community.link() }}">{{ post.community.title }}</a></li>
<li class="breadcrumb-item active">{{ post.title|shorten(15) }}</li>
</ol>
</nav>
<h1 class="mt-2">{{ post.title }}</h1>
{% if post.url %}
<p><small><a href="{{ post.url }}" rel="nofollow ugc">{{ post.url|shorten_url }}</a>
{% if post.type == post_type_link %}
<span class="domain_link">(<a href="/d/{{ post.domain_id }}">domain</a>)</span>
{% endif %}
</small></p>
{% endif %}
<p><small>submitted {{ moment(post.posted_at).fromNow() }} by {{ post.author.user_name }}</small></p>
</div>
<div class="col-4">
{% if post.url %}
<a href="post.url" rel="nofollow ugc"><img src="{{ post.image.source_url }}" alt="{{ post.image.alt_text }}"
width="100" /></a>
{% else %}
<a href="post.image.source_url"><img src="{{ post.image.source_url }}" alt="{{ post.image.alt_text }}"
width="100" /></a>
{% endif %}
</div>
{% else %}
<nav aria-label="breadcrumb" id="breadcrumb_nav" title="Navigation">
<ol class="breadcrumb">
<li class="breadcrumb-item"><a href="/">{{ _('Home') }}</a></li>
<li class="breadcrumb-item"><a href="/communities">{{ _('Communities') }}</a></li>
<li class="breadcrumb-item"><a href="/c/{{ post.community.link() }}">{{ post.community.title }}</a></li>
<li class="breadcrumb-item active">{{ post.title|shorten(15) }}</li>
</ol>
</nav>
<h1 class="mt-2">{{ post.title }}</h1>
{% if post.url %}
<p><small><a href="{{ post.url }}" rel="nofollow ugc">{{ post.url|shorten_url }}
<img src="/static/images/external_link_black.svg" class="external_link_icon" alt="External link" /></a>
{% if post.type == post_type_link %}
<span class="domain_link">(<a href="/d/{{ post.domain_id }}">domain</a>)</span>
{% endif %}
</small></p>
{% endif %}
<p class="small">submitted {{ moment(post.posted_at).fromNow() }} by
<a href="{{ url_for('activitypub.user_profile', actor=post.author.user_name) }}">{{ post.author.user_name }}</a>
</p>
{% endif %}
</div>
{% if post.body_html %}
<div class="row post_full">
<div class="col">
{{ post.body_html|safe }}
</div>
<hr />
</div>
{% endif %}

View file

@ -0,0 +1,106 @@
{% extends "base.html" %}
{% from 'bootstrap/form.html' import render_form %}
{% block app_content %}
<div class="row">
<div class="col-12 col-md-8 position-relative">
{% include 'community/_post_full.html' %}
<p><a href="{{ url_for('community.show_post', post_id=post.id, _anchor='replies') }}">Back to main discussion</a></p>
<div class="row post_replies">
<div class="col">
{% macro render_comment(comment) %}
<div id="comment_{{ comment['comment'].id }}" class="comment">
<div class="limit_height">
<div class="voting_buttons">
{% with comment=comment['comment'] %}
{% include "community/_voting_buttons.html" %}
{% endwith %}
</div>
<div class="hide_button"><a href='#'>[-] hide</a></div>
<div class="comment_author">
{% if comment['comment'].author.avatar_id %}
<a href="/u/{{ comment['comment'].author.link() }}" title="{{ comment['comment'].author.ap_id }}">
<img src="{{ comment['comment'].author.avatar_image() }}" alt="Avatar" /></a>
{% endif %}
<a href="/u/{{ comment['comment'].author.link() }}" title="{{ comment['comment'].author.link() }}">
<strong>{{ comment['comment'].author.user_name}}</strong></a>
{% if comment['comment'].author.id == post.author.id%}<span title="Submitter of original post" aria-label="submitter">[S]</span>{% endif %}
<span class="text-muted small">{{ moment(comment['comment'].posted_at).fromNow(refresh=True) }}</span>
</div>
<div class="comment_body hidable">
{{ comment['comment'].body_html | safe }}
</div>
</div>
<div class="comment_actions hidable">
<a href="{{ url_for('community.add_reply', post_id=post.id, comment_id=comment['comment'].id) }}" rel="nofollow"><span class="fe fe-reply"></span> reply</a>
</div>
{% if comment['replies'] %}
<div class="replies hidable">
{% for reply in comment['replies'] %}
{{ render_comment(reply) | safe }}
{% endfor %}
</div>
{% endif %}
</div>
{% endmacro %}
<div class="comments">
{% for reply in replies %}
{{ render_comment(reply) | safe }}
{% endfor %}
</div>
</div>
</div>
</div>
<div class="col-12 col-md-4">
<div class="card">
<div class="card-body">
<div class="row">
<div class="col-6">
{% if current_user.is_authenticated and current_user.subscribed(post.community) %}
<a class="w-100 btn btn-primary" href="/community/{{ post.community.link() }}/unsubscribe">{{ _('Unsubscribe') }}</a>
{% else %}
<a class="w-100 btn btn-primary" href="/community/{{ post.community.link() }}/subscribe">{{ _('Subscribe') }}</a>
{% endif %}
</div>
<div class="col-6">
<a class="w-100 btn btn-primary" href="/community/{{ post.community.link() }}/submit">{{ _('Create post') }}</a>
</div>
</div>
<form method="get">
<input type="search" name="search" class="form-control mt-2" placeholder="{{ _('Search this community') }}" />
</form>
</div>
</div>
<div class="card mt-3">
<div class="card-header">
<h2>{{ _('About community') }}</h2>
</div>
<div class="card-body">
<p>{{ post.community.description|safe }}</p>
<p>{{ post.community.rules|safe }}</p>
{% if len(mods) > 0 and not post.community.private_mods %}
<h3>Moderators</h3>
<ol>
{% for mod in mods %}
<li><a href="/u/{{ mod.user_name }}">{{ mod.user_name }}</a></li>
{% endfor %}
</ol>
{% endif %}
</div>
</div>
{% if is_moderator %}
<div class="card mt-3">
<div class="card-header">
<h2>{{ _('Community Settings') }}</h2>
</div>
<div class="card-body">
<p><a href="#" class="btn btn-primary">{{ _('Moderate') }}</a></p>
<p><a href="#" class="btn btn-primary">{{ _('Settings') }}</a></p>
</div>
</div>
{% endif %}
</div>
</div>
{% endblock %}

View file

@ -4,68 +4,7 @@
{% block app_content %} {% block app_content %}
<div class="row"> <div class="row">
<div class="col-12 col-md-8 position-relative"> <div class="col-12 col-md-8 position-relative">
<div class="row"> {% include 'community/_post_full.html' %}
{% if post.image_id %}
<div class="col-8">
<nav aria-label="breadcrumb" id="breadcrumb_nav" title="Navigation">
<ol class="breadcrumb">
<li class="breadcrumb-item"><a href="/">{{ _('Home') }}</a></li>
<li class="breadcrumb-item"><a href="/communities">{{ _('Communities') }}</a></li>
<li class="breadcrumb-item"><a href="/c/{{ post.community.link() }}">{{ post.community.title }}</a></li>
<li class="breadcrumb-item active">{{ post.title|shorten(15) }}</li>
</ol>
</nav>
<h1 class="mt-2">{{ post.title }}</h1>
{% if post.url %}
<p><small><a href="{{ post.url }}" rel="nofollow ugc">{{ post.url|shorten_url }}</a>
{% if post.type == post_type_link %}
<span class="domain_link">(<a href="/d/{{ post.domain_id }}">domain</a>)</span>
{% endif %}
</small></p>
{% endif %}
<p><small>submitted {{ moment(post.posted_at).fromNow() }} by {{ post.author.user_name }}</small></p>
</div>
<div class="col-4">
{% if post.url %}
<a href="post.url" rel="nofollow ugc"><img src="{{ post.image.source_url }}" alt="{{ post.image.alt_text }}"
width="100" /></a>
{% else %}
<a href="post.image.source_url"><img src="{{ post.image.source_url }}" alt="{{ post.image.alt_text }}"
width="100" /></a>
{% endif %}
</div>
{% else %}
<nav aria-label="breadcrumb" id="breadcrumb_nav" title="Navigation">
<ol class="breadcrumb">
<li class="breadcrumb-item"><a href="/">{{ _('Home') }}</a></li>
<li class="breadcrumb-item"><a href="/communities">{{ _('Communities') }}</a></li>
<li class="breadcrumb-item"><a href="/c/{{ post.community.link() }}">{{ post.community.title }}</a></li>
<li class="breadcrumb-item active">{{ post.title|shorten(15) }}</li>
</ol>
</nav>
<h1 class="mt-2">{{ post.title }}</h1>
{% if post.url %}
<p><small><a href="{{ post.url }}" rel="nofollow ugc">{{ post.url|shorten_url }}
<img src="/static/images/external_link_black.svg" class="external_link_icon" alt="External link" /></a>
{% if post.type == post_type_link %}
<span class="domain_link">(<a href="/d/{{ post.domain_id }}">domain</a>)</span>
{% endif %}
</small></p>
{% endif %}
<p class="small">submitted {{ moment(post.posted_at).fromNow() }} by
<a href="{{ url_for('activitypub.user_profile', actor=post.author.user_name) }}">{{ post.author.user_name }}</a>
</p>
{% endif %}
</div>
{% if post.body_html %}
<div class="row post_full">
<div class="col">
{{ post.body_html|safe }}
</div>
<hr />
</div>
{% endif %}
{% if post.comments_enabled %} {% if post.comments_enabled %}
<div class="row post_reply_form"> <div class="row post_reply_form">
@ -82,7 +21,7 @@
<div class="row post_replies"> <div class="row post_replies">
<div class="col"> <div class="col">
{% macro render_comment(comment) %} {% macro render_comment(comment) %}
<div id="comment_{{ comment['comment'].id }}" class="comment" style="margin-left: {{ comment['comment'].depth * 20 }}px;"> <div id="comment_{{ comment['comment'].id }}" class="comment">
<div class="limit_height"> <div class="limit_height">
<div class="voting_buttons"> <div class="voting_buttons">
{% with comment=comment['comment'] %} {% with comment=comment['comment'] %}
@ -108,17 +47,24 @@
<a href="{{ url_for('community.add_reply', post_id=post.id, comment_id=comment['comment'].id) }}" rel="nofollow"><span class="fe fe-reply"></span> reply</a> <a href="{{ url_for('community.add_reply', post_id=post.id, comment_id=comment['comment'].id) }}" rel="nofollow"><span class="fe fe-reply"></span> reply</a>
</div> </div>
{% if comment['replies'] %} {% if comment['replies'] %}
{% if comment['comment'].depth <= THREAD_CUTOFF_DEPTH %}
<div class="replies hidable"> <div class="replies hidable">
{% for reply in comment['replies'] %} {% for reply in comment['replies'] %}
{{ render_comment(reply) | safe }} {{ render_comment(reply) | safe }}
{% endfor %} {% endfor %}
</div> </div>
{% else %}
<div class="continue_thread hidable">
<a href="{{ url_for('community.continue_discussion', post_id=post.id, comment_id=comment['comment'].id) }}">
Continue thread</a>
</div>
{% endif %}
{% endif %} {% endif %}
</div> </div>
{% endmacro %} {% endmacro %}
<div class="comments"> <div id="replies" class="comments">
{% for reply in replies %} {% for reply in replies %}
{{ render_comment(reply) | safe }} {{ render_comment(reply) | safe }}
{% endfor %} {% endfor %}