show replies below posts

This commit is contained in:
rimu 2023-10-10 22:25:37 +13:00
parent 8a18573974
commit fa53128118
14 changed files with 299 additions and 28 deletions

View file

@ -1,6 +1,3 @@
import markdown2
import werkzeug.exceptions
from app import db
from app.activitypub import bp
from flask import request, Response, current_app, abort, jsonify, json
@ -14,7 +11,8 @@ from app.models import User, Community, CommunityJoinRequest, CommunityMember, C
from app.activitypub.util import public_key, users_total, active_half_year, active_month, local_posts, local_comments, \
post_to_activity, find_actor_or_create, default_context, instance_blocked, find_reply_parent, find_liked_object
from app.utils import gibberish, get_setting, is_image_url, allowlist_html, html_to_markdown, render_template, \
domain_from_url
domain_from_url, markdown_to_html
import werkzeug.exceptions
INBOX = []
@ -134,7 +132,7 @@ def user_profile(actor):
"content": user.about,
"mediaType": "text/markdown"
}
actor_data['summary'] = allowlist_html(markdown2.markdown(user.about, safe_mode=True))
actor_data['summary'] = markdown_to_html(user.about)
resp = jsonify(actor_data)
resp.content_type = 'application/activity+json'
return resp
@ -250,7 +248,7 @@ def shared_inbox():
if 'source' in request_json['object']['object'] and \
request_json['object']['object']['source']['mediaType'] == 'text/markdown':
post.body = request_json['object']['object']['source']['content']
post.body_html = allowlist_html(markdown2.markdown(post.body, safe_mode=True))
post.body_html = markdown_to_html(post.body)
elif 'content' in request_json['object']['object']:
post.body_html = allowlist_html(request_json['object']['object']['content'])
post.body = html_to_markdown(post.body_html)
@ -293,7 +291,7 @@ def shared_inbox():
request_json['object']['object']['source'][
'mediaType'] == 'text/markdown':
post_reply.body = request_json['object']['object']['source']['content']
post_reply.body_html = allowlist_html(markdown2.markdown(post_reply.body, safe_mode=True))
post_reply.body_html = markdown_to_html(post_reply.body)
elif 'content' in request_json['object']['object']:
post_reply.body_html = allowlist_html(
request_json['object']['object']['content'])

View file

@ -2,7 +2,6 @@ import json
import os
from datetime import datetime
from typing import Union, Tuple
import markdown2
from flask import current_app
from sqlalchemy import text
from app import db, cache
@ -15,7 +14,7 @@ from cryptography.hazmat.primitives.asymmetric import padding
from app.constants import *
from urllib.parse import urlparse
from app.utils import get_request, allowlist_html
from app.utils import get_request, allowlist_html, html_to_markdown
def public_key():
@ -301,7 +300,7 @@ def parse_summary(user_json) -> str:
if 'source' in user_json and user_json['source'].get('mediaType') == 'text/markdown':
# Convert Markdown to HTML
markdown_text = user_json['source']['content']
html_content = markdown2.markdown(markdown_text)
html_content = html_to_markdown(markdown_text)
return html_content
elif 'summary' in user_json:
return allowlist_html(user_json['summary'])

View file

@ -1,6 +1,5 @@
from datetime import date, datetime, timedelta
import markdown2
from flask import redirect, url_for, flash, request, make_response, session, Markup, current_app, abort
from flask_login import login_user, logout_user, current_user, login_required
from flask_babel import _
@ -9,11 +8,12 @@ from sqlalchemy import or_
from app import db
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
from app.community.util import search_for_community, community_url_exists, actor_to_community, post_replies
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
from app.models import User, Community, CommunityMember, CommunityJoinRequest, CommunityBan, Post, PostReply, \
PostReplyVote
from app.community import bp
from app.utils import get_setting, render_template, allowlist_html
from app.utils import get_setting, render_template, allowlist_html, markdown_to_html
@bp.route('/add_local', methods=['GET', 'POST'])
@ -80,7 +80,7 @@ def show_community(community: Community):
mod_list = User.query.filter(User.id.in_(mod_user_ids)).all()
if current_user.ignore_bots:
posts = community.posts.query.filter(Post.from_bot == False).all()
posts = community.posts.filter(Post.from_bot == False).all()
else:
posts = community.posts
@ -187,7 +187,7 @@ def add_post(actor):
if form.type.data == '' or form.type.data == 'discussion':
post.title = form.discussion_title.data
post.body = form.discussion_body.data
post.body_html = allowlist_html(markdown2.markdown(post.body, safe_mode=True))
post.body_html = markdown_to_html(post.body)
post.type = POST_TYPE_ARTICLE
elif form.type.data == 'link':
post.title = form.link_title.data
@ -217,11 +217,28 @@ def add_post(actor):
images_disabled=images_disabled)
@bp.route('/post/<int:post_id>')
@bp.route('/post/<int:post_id>', methods=['GET', 'POST'])
def show_post(post_id: int):
post = Post.query.get_or_404(post_id)
mods = post.community.moderators()
is_moderator = current_user.is_authenticated and any(mod.user_id == current_user.id for mod in mods)
form = NewReplyForm()
if form.validate_on_submit():
reply = PostReply(user_id=current_user.id, post_id=post.id, community_id=post.community.id, body=form.body.data,
body_html=markdown_to_html(form.body.data), body_html_safe=True,
from_bot=current_user.bot, up_votes=1, nsfw=post.nsfw, nsfl=post.nsfl)
db.session.add(reply)
db.session.commit()
reply_vote = PostReplyVote(user_id=current_user.id, author_id=current_user.id, post_reply_id=reply.id,
effect=1.0)
db.session.add(reply_vote)
db.session.commit()
form.body.data = ''
flash('Your comment has been added.')
# todo: flush cache
# todo: federation
replies = post_replies(post.id, 'top', show_first=reply.id)
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)
canonical=post.ap_id, form=form, replies=replies)

View file

@ -1,8 +1,10 @@
from datetime import datetime
from typing import List
from app import db
from app.models import Community, File, BannedInstances
from app.models import Community, File, BannedInstances, PostReply
from app.utils import get_request
from sqlalchemy import desc
def search_for_community(address: str):
@ -78,3 +80,24 @@ def actor_to_community(actor) -> Community:
else:
community = Community.query.filter_by(name=actor, banned=False, ap_id=None).first()
return community
# replies to a post, in a tree, sorted by a variety of methods
def post_replies(post_id: int, sort_by: str, show_first: int = 0) -> List[PostReply]:
comments = PostReply.query.filter_by(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'].parent_id is None]

View file

@ -198,6 +198,12 @@ class User(UserMixin, db.Model):
return self.cover.source_url
return ''
def link(self) -> str:
if self.ap_id is None:
return self.user_name
else:
return self.ap_id
def get_reset_password_token(self, expires_in=600):
return jwt.encode(
{'reset_password': self.id, 'exp': time() + expires_in},
@ -312,9 +318,11 @@ class PostReply(db.Model):
user_id = db.Column(db.Integer, db.ForeignKey('user.id'), index=True)
post_id = db.Column(db.Integer, db.ForeignKey('post.id'), index=True)
community_id = db.Column(db.Integer, db.ForeignKey('community.id'), index=True)
domain_id = db.Column(db.Integer, db.ForeignKey('domain.id'), index=True)
image_id = db.Column(db.Integer, db.ForeignKey('file.id'), index=True)
parent_id = db.Column(db.Integer)
root_id = db.Column(db.Integer)
depth = db.Column(db.Integer, default=0)
body = db.Column(db.Text)
body_html = db.Column(db.Text)
body_html_safe = db.Column(db.Boolean, default=False)
@ -327,7 +335,7 @@ class PostReply(db.Model):
from_bot = db.Column(db.Boolean, default=False)
up_votes = db.Column(db.Integer, default=0)
down_votes = db.Column(db.Integer, default=0)
ranking = db.Column(db.Integer, default=0)
ranking = db.Column(db.Integer, default=0, index=True)
language = db.Column(db.String(10))
edited_at = db.Column(db.DateTime)

View file

@ -137,6 +137,14 @@
content: "\e9d7";
}
.fe-arrow-up::before {
content: "\e914";
}
.fe-arrow-down::before {
content: "\e90c";
}
a.no-underline {
text-decoration: none;
&:hover {

View file

@ -136,6 +136,14 @@ nav, etc which are used site-wide */
content: "\e9d7";
}
.fe-arrow-up::before {
content: "\e914";
}
.fe-arrow-down::before {
content: "\e90c";
}
a.no-underline {
text-decoration: none;
}
@ -316,6 +324,10 @@ fieldset legend {
height: auto;
}
.post_reply_form label {
display: none;
}
.post_list .post_teaser {
border-bottom: solid 2px #ddd;
padding-top: 8px;
@ -329,4 +341,50 @@ fieldset legend {
text-decoration: none;
}
.comment {
clear: both;
margin-bottom: 20px;
}
.comment .comment_author img {
width: 20px;
height: 20px;
}
.comment .hide_button {
float: right;
display: block;
width: 60px;
padding: 5px;
}
.comment .hide_button a {
text-decoration: none;
}
.comment .voting_buttons {
float: right;
display: block;
width: 60px;
padding: 5px;
}
.comment .voting_buttons div {
border: solid 1px #0071CE;
}
.comment .voting_buttons .upvote_button, .comment .voting_buttons .downvote_button {
padding-left: 3px;
border-radius: 3px;
}
.comment .voting_buttons .upvote_button.digits_4, .comment .voting_buttons .downvote_button.digits_4 {
width: 68px;
}
.comment .voting_buttons .upvote_button.digits_5, .comment .voting_buttons .downvote_button.digits_5 {
width: 76px;
}
.comment .voting_buttons .upvote_button.digits_6, .comment .voting_buttons .downvote_button.digits_6 {
width: 84px;
}
.comment .voting_buttons .downvote_button {
margin-top: 5px;
}
.comment .voting_buttons a {
text-decoration: none;
}
/*# sourceMappingURL=structure.css.map */

View file

@ -111,6 +111,11 @@ nav, etc which are used site-wide */
}
}
.post_reply_form {
label {
display: none;
}
}
.post_list {
.post_teaser {
@ -129,4 +134,63 @@ nav, etc which are used site-wide */
padding-top: 8px;
padding-bottom: 8px;
}
}
.comment {
clear: both;
margin-bottom: 20px;
.comment_author {
img {
width: 20px;
height: 20px;
}
}
.hide_button {
float: right;
display: block;
width: 60px;
padding: 5px;
a {
text-decoration: none;
}
}
.voting_buttons {
float: right;
display: block;
width: 60px;
padding: 5px;
div {
border: solid 1px $primary-colour;
}
.upvote_button, .downvote_button {
padding-left: 3px;
border-radius: 3px;
&.digits_4 {
width: 68px;
}
&.digits_5 {
width: 76px;
}
&.digits_6 {
width: 84px;
}
}
.downvote_button {
margin-top: 5px;
}
a {
text-decoration: none;
}
}
}

View file

@ -135,6 +135,14 @@
content: "\e9d7";
}
.fe-arrow-up::before {
content: "\e914";
}
.fe-arrow-down::before {
content: "\e90c";
}
a.no-underline {
text-decoration: none;
}

View file

@ -79,7 +79,42 @@
{% endif %}
<div class="row post_replies">
<div class="col">
{% macro render_comment(comment) %}
<div class="comment" style="margin-left: {{ comment['comment'].depth * 20 }}px;">
<div class="voting_buttons">
<div class="upvote_button digits_{{ digits(comment['comment'].up_votes) }}"><a href="#"><span class="fe fe-arrow-up"></span>
{{ comment['comment'].up_votes }}</a>
</div>
<div class="downvote_button digits_{{ digits(comment['comment'].down_votes) }}"><a href="#"><span class="fe fe-arrow-down"></span>
{{ comment['comment'].down_votes }}</a>
</div>
</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>
<span class="text-muted small">{{ moment(comment['comment'].posted_at).fromNow(refresh=True) }}</span>
</div>
{{ comment['comment'].body_html | safe }}
</div>
{% if comment['replies'] %}
<div class="replies">
{% for reply in comment['replies'] %}
{{ render_comment(reply) | safe }}
{% endfor %}
</div>
{% endif %}
{% endmacro %}
<div class="comments">
{% for reply in replies %}
{{ render_comment(reply) | safe }}
{% endfor %}
</div>
</div>
</div>
</div>

View file

@ -1,5 +1,3 @@
import markdown2
from flask import redirect, url_for, flash, request, make_response, session, Markup, current_app, abort
from flask_login import login_user, logout_user, current_user, login_required
from flask_babel import _
@ -8,7 +6,7 @@ from app import db
from app.models import Post, Community, CommunityMember, User, PostReply
from app.user import bp
from app.user.forms import ProfileForm, SettingsForm
from app.utils import get_setting, render_template, allowlist_html
from app.utils import get_setting, render_template, markdown_to_html
from sqlalchemy import desc, or_
@ -19,7 +17,7 @@ def show_profile(user):
moderates = moderates.filter(Community.private_mods == False)
post_replies = PostReply.query.filter_by(user_id=user.id).order_by(desc(PostReply.posted_at)).all()
canonical = user.ap_public_url if user.ap_public_url else None
user.about_html = allowlist_html(markdown2.markdown(user.about, safe_mode=True))
user.about_html = markdown_to_html(user.about)
return render_template('user/show_profile.html', user=user, posts=posts, post_replies=post_replies,
moderates=moderates.all(), canonical=canonical, title=_('Posts by %(user_name)s',
user_name=user.user_name))

View file

@ -1,9 +1,10 @@
import random
import markdown2
import math
from urllib.parse import urlparse
import flask
from bs4 import BeautifulSoup
import html as html_module
import requests
import os
from flask import current_app, json
@ -84,7 +85,8 @@ def is_image_url(url):
# sanitise HTML using an allow list
def allowlist_html(html: str) -> str:
allowed_tags = ['p', 'strong', 'a', 'ul', 'ol', 'li', 'em', 'blockquote', 'cite', 'br', 'h3', 'h4', 'h5']
allowed_tags = ['p', 'strong', 'a', 'ul', 'ol', 'li', 'em', 'blockquote', 'cite', 'br', 'h3', 'h4', 'h5', 'pre',
'code']
# Parse the HTML using BeautifulSoup
soup = BeautifulSoup(html, 'html.parser')
@ -100,7 +102,7 @@ def allowlist_html(html: str) -> str:
del tag[attr]
# Encode the HTML to prevent script execution
return html_module.escape(str(soup))
return str(soup)
# convert basic HTML to Markdown
@ -138,6 +140,10 @@ def html_to_markdown_worker(element, indent_level=0):
return formatted_text
def markdown_to_html(markdown_text) -> str:
return allowlist_html(markdown2.markdown(markdown_text, safe_mode=True))
def domain_from_url(url: str) -> Domain:
parsed_url = urlparse(url)
domain = Domain.query.filter_by(name=parsed_url.hostname.lower()).first()
@ -153,3 +159,11 @@ def shorten_string(input_str, max_length=50):
def shorten_url(input: str, max_length=20):
return shorten_string(input.replace('https://', '').replace('http://', ''))
# the number of digits in a number. e.g. 1000 would be 4
def digits(input: int) -> int:
if input == 0:
return 1 # Special case: 0 has 1 digit
else:
return math.floor(math.log10(abs(input))) + 1

View file

@ -0,0 +1,40 @@
"""reply depth
Revision ID: e82f86c550ac
Revises: 8c5cc19e0670
Create Date: 2023-10-10 20:51:09.662080
"""
from alembic import op
import sqlalchemy as sa
# revision identifiers, used by Alembic.
revision = 'e82f86c550ac'
down_revision = '8c5cc19e0670'
branch_labels = None
depends_on = None
def upgrade():
# ### commands auto generated by Alembic - please adjust! ###
with op.batch_alter_table('post_reply', schema=None) as batch_op:
batch_op.add_column(sa.Column('domain_id', sa.Integer(), nullable=True))
batch_op.add_column(sa.Column('depth', sa.Integer(), nullable=True))
batch_op.create_index(batch_op.f('ix_post_reply_domain_id'), ['domain_id'], unique=False)
batch_op.create_index(batch_op.f('ix_post_reply_ranking'), ['ranking'], unique=False)
batch_op.create_foreign_key(None, 'domain', ['domain_id'], ['id'])
# ### end Alembic commands ###
def downgrade():
# ### commands auto generated by Alembic - please adjust! ###
with op.batch_alter_table('post_reply', schema=None) as batch_op:
batch_op.drop_constraint(None, type_='foreignkey')
batch_op.drop_index(batch_op.f('ix_post_reply_ranking'))
batch_op.drop_index(batch_op.f('ix_post_reply_domain_id'))
batch_op.drop_column('depth')
batch_op.drop_column('domain_id')
# ### end Alembic commands ###

View file

@ -6,7 +6,7 @@ from app import create_app, db, cli
import os, click
from flask import session, g
from app.constants import POST_TYPE_LINK, POST_TYPE_IMAGE, POST_TYPE_ARTICLE
from app.utils import getmtime, gibberish, shorten_string, shorten_url
from app.utils import getmtime, gibberish, shorten_string, shorten_url, digits
app = create_app()
cli.register(app)
@ -27,6 +27,7 @@ def make_shell_context():
with app.app_context():
app.jinja_env.globals['getmtime'] = getmtime
app.jinja_env.globals['len'] = len
app.jinja_env.globals['digits'] = digits
app.jinja_env.globals['str'] = str
app.jinja_env.filters['shorten'] = shorten_string
app.jinja_env.filters['shorten_url'] = shorten_url