From fa5312811881c325796fbe82bb2ed65f232c3c18 Mon Sep 17 00:00:00 2001 From: rimu <3310831+rimu@users.noreply.github.com> Date: Tue, 10 Oct 2023 22:25:37 +1300 Subject: [PATCH] show replies below posts --- app/activitypub/routes.py | 12 ++-- app/activitypub/util.py | 5 +- app/community/routes.py | 33 +++++++--- app/community/util.py | 25 +++++++- app/models.py | 10 ++- app/static/scss/_typography.scss | 8 +++ app/static/structure.css | 58 +++++++++++++++++ app/static/structure.scss | 64 +++++++++++++++++++ app/static/styles.css | 8 +++ app/templates/community/post.html | 35 ++++++++++ app/user/routes.py | 6 +- app/utils.py | 20 +++++- .../versions/e82f86c550ac_reply_depth.py | 40 ++++++++++++ pyfedi.py | 3 +- 14 files changed, 299 insertions(+), 28 deletions(-) create mode 100644 migrations/versions/e82f86c550ac_reply_depth.py diff --git a/app/activitypub/routes.py b/app/activitypub/routes.py index d4707f5f..93419690 100644 --- a/app/activitypub/routes.py +++ b/app/activitypub/routes.py @@ -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']) diff --git a/app/activitypub/util.py b/app/activitypub/util.py index 07a598ae..8701b2e2 100644 --- a/app/activitypub/util.py +++ b/app/activitypub/util.py @@ -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']) diff --git a/app/community/routes.py b/app/community/routes.py index ffc0c535..c4346b45 100644 --- a/app/community/routes.py +++ b/app/community/routes.py @@ -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/') +@bp.route('/post/', 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) diff --git a/app/community/util.py b/app/community/util.py index 4c70944b..1e399170 100644 --- a/app/community/util.py +++ b/app/community/util.py @@ -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] diff --git a/app/models.py b/app/models.py index ff4283a7..a721d56d 100644 --- a/app/models.py +++ b/app/models.py @@ -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) diff --git a/app/static/scss/_typography.scss b/app/static/scss/_typography.scss index aa79b5e0..1b2f1b0c 100644 --- a/app/static/scss/_typography.scss +++ b/app/static/scss/_typography.scss @@ -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 { diff --git a/app/static/structure.css b/app/static/structure.css index 38103700..aef08294 100644 --- a/app/static/structure.css +++ b/app/static/structure.css @@ -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 */ diff --git a/app/static/structure.scss b/app/static/structure.scss index 22708c83..3d664932 100644 --- a/app/static/structure.scss +++ b/app/static/structure.scss @@ -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; + } + } } \ No newline at end of file diff --git a/app/static/styles.css b/app/static/styles.css index 26e15124..112f60dc 100644 --- a/app/static/styles.css +++ b/app/static/styles.css @@ -135,6 +135,14 @@ content: "\e9d7"; } +.fe-arrow-up::before { + content: "\e914"; +} + +.fe-arrow-down::before { + content: "\e90c"; +} + a.no-underline { text-decoration: none; } diff --git a/app/templates/community/post.html b/app/templates/community/post.html index b7ad0896..acc9de67 100644 --- a/app/templates/community/post.html +++ b/app/templates/community/post.html @@ -79,7 +79,42 @@ {% endif %}
+ {% macro render_comment(comment) %} +
+ + +
+ {% if comment['comment'].author.avatar_id %} + + Avatar + {% endif %} + + {{ comment['comment'].author.user_name}} + {{ moment(comment['comment'].posted_at).fromNow(refresh=True) }} +
+ {{ comment['comment'].body_html | safe }} +
+ {% if comment['replies'] %} +
+ {% for reply in comment['replies'] %} + {{ render_comment(reply) | safe }} + {% endfor %} +
+ {% endif %} + {% endmacro %} +
+ {% for reply in replies %} + {{ render_comment(reply) | safe }} + {% endfor %} +
diff --git a/app/user/routes.py b/app/user/routes.py index 93fc6284..320a6a8a 100644 --- a/app/user/routes.py +++ b/app/user/routes.py @@ -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)) diff --git a/app/utils.py b/app/utils.py index 270deead..a7b6adf4 100644 --- a/app/utils.py +++ b/app/utils.py @@ -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 diff --git a/migrations/versions/e82f86c550ac_reply_depth.py b/migrations/versions/e82f86c550ac_reply_depth.py new file mode 100644 index 00000000..567536fd --- /dev/null +++ b/migrations/versions/e82f86c550ac_reply_depth.py @@ -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 ### diff --git a/pyfedi.py b/pyfedi.py index 20552874..dec9e59b 100644 --- a/pyfedi.py +++ b/pyfedi.py @@ -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