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 sqlalchemy import or_
from app import db
from app import db, constants
from app.activitypub.signature import RsaKeys, HttpSignature
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.models import User, Community, CommunityMember, CommunityJoinRequest, CommunityBan, Post, PostReply, \
PostReplyVote
@ -81,7 +82,7 @@ def show_community(community: Community):
mod_user_ids = [mod.user_id for mod in mods]
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()
else:
posts = community.posts
@ -244,7 +245,7 @@ def show_post(post_id: int):
else:
replies = post_replies(post.id, 'top')
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'])
@ -295,8 +296,15 @@ def comment_vote(comment_id, vote_direction):
@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'])
@ -321,7 +329,10 @@ def add_reply(post_id: int, comment_id: int):
flash('Your comment has been added.')
# todo: flush cache
# 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:
return render_template('community/add_reply.html', title=_('Discussing %(title)s', title=post.title), post=post,
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])
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_BANNED = -1
THREAD_CUTOFF_DEPTH = 4

View file

@ -18,3 +18,7 @@
@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,
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/ */
/* use https://fontdrop.info/ to get the unicode values of the featuer.ttf file */
@font-face {
@ -362,9 +366,22 @@ fieldset legend {
text-decoration: none;
}
.comments > .comment {
margin-left: 0;
}
#replies {
scroll-margin-top: 5em;
}
.post_replies > .col {
padding-right: 5px;
}
.comment {
clear: both;
margin-bottom: 20px;
margin-left: 20px;
}
.comment .limit_height {
position: relative;
@ -439,6 +456,10 @@ fieldset legend {
.comment .comment_actions a {
text-decoration: none;
}
.comment .replies {
margin-top: 15px;
border-left: solid 1px #ddd;
}
.add_reply .form-control-label {
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 {
clear: both;
margin-bottom: 20px;
margin-left: 20px;
.limit_height {
position: relative;
@ -231,6 +244,11 @@ nav, etc which are used site-wide */
text-decoration: none;
}
}
.replies {
margin-top: 15px;
border-left: solid 1px $light-grey;
}
}
.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/ */
/* use https://fontdrop.info/ to get the unicode values of the featuer.ttf file */
@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 %}
<div class="row">
<div class="col-12 col-md-8 position-relative">
<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 %}
{% include 'community/_post_full.html' %}
{% if post.comments_enabled %}
<div class="row post_reply_form">
@ -82,7 +21,7 @@
<div class="row post_replies">
<div class="col">
{% 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="voting_buttons">
{% with comment=comment['comment'] %}
@ -96,8 +35,8 @@
<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 %}
<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">
@ -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>
</div>
{% if comment['replies'] %}
<div class="replies hidable">
{% for reply in comment['replies'] %}
{{ render_comment(reply) | safe }}
{% endfor %}
</div>
{% if comment['comment'].depth <= THREAD_CUTOFF_DEPTH %}
<div class="replies hidable">
{% for reply in comment['replies'] %}
{{ render_comment(reply) | safe }}
{% endfor %}
</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 %}
</div>
{% endmacro %}
<div class="comments">
<div id="replies" class="comments">
{% for reply in replies %}
{{ render_comment(reply) | safe }}
{% endfor %}