diff --git a/app/activitypub/routes.py b/app/activitypub/routes.py index 8a55f262..d84c5794 100644 --- a/app/activitypub/routes.py +++ b/app/activitypub/routes.py @@ -458,7 +458,7 @@ def process_inbox_request(request_json, activitypublog_id, ip_address): # Notify recipient notify = Notification(title=shorten_string('New message from ' + sender.display_name()), - url=f'/chat/{existing_conversation.id}', user_id=recipient.id, + url=f'/chat/{existing_conversation.id}#message_{new_message}', user_id=recipient.id, author_id=sender.id) db.session.add(notify) recipient.unread_notifications += 1 diff --git a/app/activitypub/util.py b/app/activitypub/util.py index f6563c1e..e110608b 100644 --- a/app/activitypub/util.py +++ b/app/activitypub/util.py @@ -12,7 +12,8 @@ from flask_babel import _ from sqlalchemy import text, func from app import db, cache, constants, celery from app.models import User, Post, Community, BannedInstances, File, PostReply, AllowedInstances, Instance, utcnow, \ - PostVote, PostReplyVote, ActivityPubLog, Notification, Site, CommunityMember, InstanceRole, Report, Conversation + PostVote, PostReplyVote, ActivityPubLog, Notification, Site, CommunityMember, InstanceRole, Report, Conversation, \ + Language import time import base64 import requests @@ -27,7 +28,7 @@ import pytesseract from app.utils import get_request, allowlist_html, get_setting, ap_datetime, markdown_to_html, \ is_image_url, domain_from_url, gibberish, ensure_directory_exists, markdown_to_text, head_request, post_ranking, \ shorten_string, reply_already_exists, reply_is_just_link_to_gif_reaction, confidence, remove_tracking_from_link, \ - blocked_phrases, microblog_content_to_title + blocked_phrases, microblog_content_to_title, generate_image_from_video_url, is_video_url def public_key(): @@ -171,7 +172,7 @@ def post_to_activity(post: Post, community: Community): activity_data["object"]["object"]["updated"] = ap_datetime(post.edited_at) if post.language is not None: activity_data["object"]["object"]["language"] = {"identifier": post.language} - if post.type == POST_TYPE_LINK and post.url is not None: + if (post.type == POST_TYPE_LINK or post.type == POST_TYPE_VIDEO) and post.url is not None: activity_data["object"]["object"]["attachment"] = [{"href": post.url, "type": "Link"}] if post.image_id is not None: activity_data["object"]["object"]["image"] = {"url": post.image.view_url(), "type": "Image"} @@ -208,7 +209,7 @@ def post_to_page(post: Post, community: Community): activity_data["updated"] = ap_datetime(post.edited_at) if post.language is not None: activity_data["language"] = {"identifier": post.language} - if post.type == POST_TYPE_LINK and post.url is not None: + if (post.type == POST_TYPE_LINK or post.type == POST_TYPE_VIDEO) and post.url is not None: activity_data["attachment"] = [{"href": post.url, "type": "Link"}] if post.image_id is not None: activity_data["image"] = {"url": post.image.view_url(), "type": "Image"} @@ -342,6 +343,16 @@ def find_actor_or_create(actor: str, create_if_not_found=True, community_only=Fa return None +def find_language_or_create(code: str, name: str) -> Language: + existing_language = Language.query.filter(Language.code == code).first() + if existing_language: + return existing_language + else: + new_language = Language(code=code, name=name) + db.session.add(new_language) + return new_language + + def extract_domain_and_actor(url_string: str): # Parse the URL parsed_url = urlparse(url_string) @@ -637,6 +648,9 @@ def actor_json_to_model(activity_json, address, server): image = File(source_url=activity_json['image']['url']) community.image = image db.session.add(image) + if 'language' in activity_json and isinstance(activity_json['language'], list): + for ap_language in activity_json['language']: + community.languages.append(find_language_or_create(ap_language['identifier'], ap_language['name'])) db.session.add(community) db.session.commit() if community.icon_id: @@ -738,78 +752,117 @@ def make_image_sizes(file_id, thumbnail_width=50, medium_width=120, directory='p def make_image_sizes_async(file_id, thumbnail_width, medium_width, directory): file = File.query.get(file_id) if file and file.source_url: - try: - source_image_response = get_request(file.source_url) - except: - pass + # Videos + if file.source_url.endswith('.mp4') or file.source_url.endswith('.webm'): + new_filename = gibberish(15) + + # set up the storage directory + directory = f'app/static/media/{directory}/' + new_filename[0:2] + '/' + new_filename[2:4] + ensure_directory_exists(directory) + + # file path and names to store the resized images on disk + final_place = os.path.join(directory, new_filename + '.jpg') + final_place_thumbnail = os.path.join(directory, new_filename + '_thumbnail.webp') + + generate_image_from_video_url(file.source_url, final_place) + + image = Image.open(final_place) + img_width = image.width + + # Resize the image to medium + if medium_width: + if img_width > medium_width: + image.thumbnail((medium_width, medium_width)) + image.save(final_place) + file.file_path = final_place + file.width = image.width + file.height = image.height + + # Resize the image to a thumbnail (webp) + if thumbnail_width: + if img_width > thumbnail_width: + image.thumbnail((thumbnail_width, thumbnail_width)) + image.save(final_place_thumbnail, format="WebP", quality=93) + file.thumbnail_path = final_place_thumbnail + file.thumbnail_width = image.width + file.thumbnail_height = image.height + + db.session.commit() + + # Images else: - if source_image_response.status_code == 200: - content_type = source_image_response.headers.get('content-type') - if content_type and content_type.startswith('image'): - source_image = source_image_response.content - source_image_response.close() + try: + source_image_response = get_request(file.source_url) + except: + pass + else: + if source_image_response.status_code == 200: + content_type = source_image_response.headers.get('content-type') + if content_type and content_type.startswith('image'): + source_image = source_image_response.content + source_image_response.close() - file_ext = os.path.splitext(file.source_url)[1] - # fall back to parsing the http content type if the url does not contain a file extension - if file_ext == '': - content_type_parts = content_type.split('/') - if content_type_parts: - file_ext = '.' + content_type_parts[-1] - else: - if '?' in file_ext: - file_ext = file_ext.split('?')[0] + file_ext = os.path.splitext(file.source_url)[1] + # fall back to parsing the http content type if the url does not contain a file extension + if file_ext == '': + content_type_parts = content_type.split('/') + if content_type_parts: + file_ext = '.' + content_type_parts[-1] + else: + if '?' in file_ext: + file_ext = file_ext.split('?')[0] - new_filename = gibberish(15) + new_filename = gibberish(15) - # set up the storage directory - directory = f'app/static/media/{directory}/' + new_filename[0:2] + '/' + new_filename[2:4] - ensure_directory_exists(directory) + # set up the storage directory + directory = f'app/static/media/{directory}/' + new_filename[0:2] + '/' + new_filename[2:4] + ensure_directory_exists(directory) - # file path and names to store the resized images on disk - final_place = os.path.join(directory, new_filename + file_ext) - final_place_thumbnail = os.path.join(directory, new_filename + '_thumbnail.webp') + # file path and names to store the resized images on disk + final_place = os.path.join(directory, new_filename + file_ext) + final_place_thumbnail = os.path.join(directory, new_filename + '_thumbnail.webp') - # Load image data into Pillow - Image.MAX_IMAGE_PIXELS = 89478485 - image = Image.open(BytesIO(source_image)) - image = ImageOps.exif_transpose(image) - img_width = image.width - img_height = image.height + # Load image data into Pillow + Image.MAX_IMAGE_PIXELS = 89478485 + image = Image.open(BytesIO(source_image)) + image = ImageOps.exif_transpose(image) + img_width = image.width + img_height = image.height - # Resize the image to medium - if medium_width: - if img_width > medium_width: - image.thumbnail((medium_width, medium_width)) - image.save(final_place) - file.file_path = final_place - file.width = image.width - file.height = image.height + # Resize the image to medium + if medium_width: + if img_width > medium_width: + image.thumbnail((medium_width, medium_width)) + image.save(final_place) + file.file_path = final_place + file.width = image.width + file.height = image.height - # Resize the image to a thumbnail (webp) - if thumbnail_width: - if img_width > thumbnail_width: - image.thumbnail((thumbnail_width, thumbnail_width)) - image.save(final_place_thumbnail, format="WebP", quality=93) - file.thumbnail_path = final_place_thumbnail - file.thumbnail_width = image.width - file.thumbnail_height = image.height + # Resize the image to a thumbnail (webp) + if thumbnail_width: + if img_width > thumbnail_width: + image.thumbnail((thumbnail_width, thumbnail_width)) + image.save(final_place_thumbnail, format="WebP", quality=93) + file.thumbnail_path = final_place_thumbnail + file.thumbnail_width = image.width + file.thumbnail_height = image.height - db.session.commit() + db.session.commit() - # Alert regarding fascist meme content - if img_width < 2000: # images > 2000px tend to be real photos instead of 4chan screenshots. - try: - image_text = pytesseract.image_to_string(Image.open(BytesIO(source_image)).convert('L'), timeout=30) - except FileNotFoundError as e: - image_text = '' - if 'Anonymous' in image_text and ('No.' in image_text or ' N0' in image_text): # chan posts usually contain the text 'Anonymous' and ' No.12345' - post = Post.query.filter_by(image_id=file.id).first() - notification = Notification(title='Review this', - user_id=1, - author_id=post.user_id, - url=url_for('activitypub.post_ap', post_id=post.id)) - db.session.add(notification) - db.session.commit() + # Alert regarding fascist meme content + if img_width < 2000: # images > 2000px tend to be real photos instead of 4chan screenshots. + try: + image_text = pytesseract.image_to_string(Image.open(BytesIO(source_image)).convert('L'), timeout=30) + except FileNotFoundError as e: + image_text = '' + if 'Anonymous' in image_text and ('No.' in image_text or ' N0' in image_text): # chan posts usually contain the text 'Anonymous' and ' No.12345' + post = Post.query.filter_by(image_id=file.id).first() + notification = Notification(title='Review this', + user_id=1, + author_id=post.user_id, + url=url_for('activitypub.post_ap', post_id=post.id)) + db.session.add(notification) + db.session.commit() # create a summary from markdown if present, otherwise use html if available @@ -1037,7 +1090,13 @@ def downvote_post(post, user): if not existing_vote: effect = -1.0 post.down_votes += 1 - post.score -= 1.0 + # Make 'hot' sort more spicy by amplifying the effect of early downvotes + if post.up_votes + post.down_votes <= 30: + post.score -= current_app.config['SPICY_UNDER_30'] + elif post.up_votes + post.down_votes <= 60: + post.score -= current_app.config['SPICY_UNDER_60'] + else: + post.score -= 1.0 vote = PostVote(user_id=user.id, post_id=post.id, author_id=post.author.id, effect=effect) post.author.reputation += effect @@ -1139,10 +1198,18 @@ def upvote_post(post, user): user.last_seen = utcnow() user.recalculate_attitude() effect = instance_weight(user.ap_domain) + # Make 'hot' sort more spicy by amplifying the effect of early upvotes + spicy_effect = effect + if post.up_votes + post.down_votes <= 10: + spicy_effect = effect * current_app.config['SPICY_UNDER_10'] + elif post.up_votes + post.down_votes <= 30: + spicy_effect = effect * current_app.config['SPICY_UNDER_30'] + elif post.up_votes + post.down_votes <= 60: + spicy_effect = effect * current_app.config['SPICY_UNDER_60'] existing_vote = PostVote.query.filter_by(user_id=user.id, post_id=post.id).first() if not existing_vote: post.up_votes += 1 - post.score += effect + post.score += spicy_effect vote = PostVote(user_id=user.id, post_id=post.id, author_id=post.author.id, effect=effect) if post.community.low_quality and effect > 0: @@ -1373,6 +1440,11 @@ def create_post(activity_log: ActivityPubLog, community: Community, request_json image = File(source_url=post.url) db.session.add(image) post.image = image + elif is_video_url(post.url): + post.type = POST_TYPE_VIDEO + image = File(source_url=post.url) + db.session.add(image) + post.image = image else: post.type = POST_TYPE_LINK post.url = remove_tracking_from_link(post.url) @@ -1399,6 +1471,9 @@ def create_post(activity_log: ActivityPubLog, community: Community, request_json else: post = None activity_log.exception_message = domain.name + ' is blocked by admin' + if 'language' in request_json['object'] and isinstance(request_json['object']['language'], dict): + language = find_language_or_create(request_json['object']['language']['identifier'], request_json['object']['language']['name']) + post.language_id = language.id if post is not None: if 'image' in request_json['object'] and post.image is None: image = File(source_url=request_json['object']['image']['url']) @@ -1483,6 +1558,11 @@ def update_post_from_activity(post: Post, request_json: dict): name += ' ' + microblog_content_to_title(post.body_html) nsfl_in_title = '[NSFL]' in name.upper() or '(NSFL)' in name.upper() post.title = name + # Language + if 'language' in request_json['object'] and isinstance(request_json['object']['language'], dict): + language = find_language_or_create(request_json['object']['language']['identifier'], request_json['object']['language']['name']) + post.language_id = language.id + # Links old_url = post.url old_image_id = post.image_id post.url = '' @@ -1510,6 +1590,11 @@ def update_post_from_activity(post: Post, request_json: dict): image = File(source_url=post.url) db.session.add(image) post.image = image + elif is_video_url(post.url): + post.type == POST_TYPE_VIDEO + image = File(source_url=post.url) + db.session.add(image) + post.image = image else: post.type = POST_TYPE_LINK post.url = remove_tracking_from_link(post.url) @@ -1536,6 +1621,7 @@ def update_post_from_activity(post: Post, request_json: dict): else: post.url = old_url # don't change if url changed from non-banned domain to banned domain + # Posts which link to the same url as other posts new_cross_posts = Post.query.filter(Post.id != post.id, Post.url == post.url, Post.posted_at > utcnow() - timedelta(days=6)).all() for ncp in new_cross_posts: diff --git a/app/chat/util.py b/app/chat/util.py index 8b149641..4f5114b9 100644 --- a/app/chat/util.py +++ b/app/chat/util.py @@ -14,21 +14,20 @@ def send_message(message: str, conversation_id: int) -> ChatMessage: reply = ChatMessage(sender_id=current_user.id, conversation_id=conversation.id, body=message, body_html=allowlist_html(markdown_to_html(message))) conversation.updated_at = utcnow() + db.session.add(reply) + db.session.commit() for recipient in conversation.members: if recipient.id != current_user.id: if recipient.is_local(): # Notify local recipient notify = Notification(title=shorten_string('New message from ' + current_user.display_name()), - url='/chat/' + str(conversation_id), + url=f'/chat/{conversation_id}#message_{reply.id}', user_id=recipient.id, author_id=current_user.id) db.session.add(notify) recipient.unread_notifications += 1 - db.session.add(reply) db.session.commit() else: - db.session.add(reply) - db.session.commit() # Federate reply reply_json = { "actor": current_user.profile_id(), diff --git a/app/community/forms.py b/app/community/forms.py index 480ec26b..caace83a 100644 --- a/app/community/forms.py +++ b/app/community/forms.py @@ -116,6 +116,26 @@ class CreateLinkForm(FlaskForm): return True +class CreateVideoForm(FlaskForm): + communities = SelectField(_l('Community'), validators=[DataRequired()], coerce=int) + video_title = StringField(_l('Title'), validators=[DataRequired(), Length(min=3, max=255)]) + video_body = TextAreaField(_l('Body'), validators=[Optional(), Length(min=3, max=5000)], render_kw={'rows': 5}) + video_url = StringField(_l('URL'), validators=[DataRequired(), Regexp(r'^https?://', message='Submitted links need to start with "http://"" or "https://"')], + render_kw={'placeholder': 'https://...'}) + sticky = BooleanField(_l('Sticky')) + nsfw = BooleanField(_l('NSFW')) + nsfl = BooleanField(_l('Gore/gross')) + notify_author = BooleanField(_l('Notify about replies')) + submit = SubmitField(_l('Save')) + + def validate(self, extra_validators=None) -> bool: + domain = domain_from_url(self.video_url.data, create=False) + if domain and domain.banned: + self.video_url.errors.append(_("Videos from %(domain)s are not allowed.", domain=domain.name)) + return False + return True + + class CreateImageForm(FlaskForm): communities = SelectField(_l('Community'), validators=[DataRequired()], coerce=int) image_title = StringField(_l('Title'), validators=[DataRequired(), Length(min=3, max=255)]) diff --git a/app/community/routes.py b/app/community/routes.py index 96b53bdb..63d41c0a 100644 --- a/app/community/routes.py +++ b/app/community/routes.py @@ -10,17 +10,18 @@ from sqlalchemy import or_, desc, text from app import db, constants, cache from app.activitypub.signature import RsaKeys, post_request -from app.activitypub.util import default_context, notify_about_post, find_actor_or_create, make_image_sizes +from app.activitypub.util import default_context, notify_about_post, make_image_sizes from app.chat.util import send_message -from app.community.forms import SearchRemoteCommunity, CreateDiscussionForm, CreateImageForm, CreateLinkForm, ReportCommunityForm, \ +from app.community.forms import SearchRemoteCommunity, CreateDiscussionForm, CreateImageForm, CreateLinkForm, \ + ReportCommunityForm, \ DeleteCommunityForm, AddCommunityForm, EditCommunityForm, AddModeratorForm, BanUserCommunityForm, \ - EscalateReportForm, ResolveReportForm + EscalateReportForm, ResolveReportForm, CreateVideoForm from app.community.util import search_for_community, community_url_exists, actor_to_community, \ opengraph_parse, url_to_thumbnail_file, save_post, save_icon_file, save_banner_file, send_to_remote_instance, \ delete_post_from_community, delete_post_reply_from_community from app.constants import SUBSCRIPTION_MEMBER, SUBSCRIPTION_OWNER, POST_TYPE_LINK, POST_TYPE_ARTICLE, POST_TYPE_IMAGE, \ SUBSCRIPTION_PENDING, SUBSCRIPTION_MODERATOR, REPORT_STATE_NEW, REPORT_STATE_ESCALATED, REPORT_STATE_RESOLVED, \ - REPORT_STATE_DISCARDED + REPORT_STATE_DISCARDED, POST_TYPE_VIDEO from app.inoculation import inoculation from app.models import User, Community, CommunityMember, CommunityJoinRequest, CommunityBan, Post, \ File, PostVote, utcnow, Report, Notification, InstanceBlock, ActivityPubLog, Topic, Conversation, PostReply @@ -30,7 +31,8 @@ from app.utils import get_setting, render_template, allowlist_html, markdown_to_ shorten_string, gibberish, community_membership, ap_datetime, \ request_etag_matches, return_304, instance_banned, can_create_post, can_upvote, can_downvote, user_filters_posts, \ joined_communities, moderating_communities, blocked_domains, mimetype_from_url, blocked_instances, \ - community_moderators, communities_banned_from, show_ban_message, recently_upvoted_posts, recently_downvoted_posts + community_moderators, communities_banned_from, show_ban_message, recently_upvoted_posts, recently_downvoted_posts, \ + blocked_users from feedgen.feed import FeedGenerator from datetime import timezone, timedelta @@ -181,10 +183,15 @@ def show_community(community: Community): if instance_ids: posts = posts.filter(or_(Post.instance_id.not_in(instance_ids), Post.instance_id == None)) + # filter blocked users + blocked_accounts = blocked_users(current_user.id) + if blocked_accounts: + posts = posts.filter(Post.user_id.not_in(blocked_accounts)) + if sort == '' or sort == 'hot': posts = posts.order_by(desc(Post.sticky)).order_by(desc(Post.ranking)).order_by(desc(Post.posted_at)) elif sort == 'top': - posts = posts.filter(Post.posted_at > utcnow() - timedelta(days=7)).order_by(desc(Post.sticky)).order_by(desc(Post.score)) + posts = posts.filter(Post.posted_at > utcnow() - timedelta(days=7)).order_by(desc(Post.sticky)).order_by(desc(Post.up_votes - Post.down_votes)) elif sort == 'new': posts = posts.order_by(desc(Post.posted_at)) elif sort == 'active': @@ -251,7 +258,7 @@ def show_community(community: Community): return render_template('community/community.html', community=community, title=community.title, breadcrumbs=breadcrumbs, is_moderator=is_moderator, is_owner=is_owner, is_admin=is_admin, mods=mod_list, posts=posts, description=description, - og_image=og_image, POST_TYPE_IMAGE=POST_TYPE_IMAGE, POST_TYPE_LINK=POST_TYPE_LINK, SUBSCRIPTION_PENDING=SUBSCRIPTION_PENDING, + og_image=og_image, POST_TYPE_IMAGE=POST_TYPE_IMAGE, POST_TYPE_LINK=POST_TYPE_LINK, POST_TYPE_VIDEO=POST_TYPE_VIDEO, SUBSCRIPTION_PENDING=SUBSCRIPTION_PENDING, SUBSCRIPTION_MEMBER=SUBSCRIPTION_MEMBER, SUBSCRIPTION_OWNER=SUBSCRIPTION_OWNER, SUBSCRIPTION_MODERATOR=SUBSCRIPTION_MODERATOR, etag=f"{community.id}{sort}{post_layout}_{hash(community.last_active)}", related_communities=related_communities, next_url=next_url, prev_url=prev_url, low_bandwidth=low_bandwidth, @@ -652,6 +659,79 @@ def add_link_post(actor): ) +@bp.route('//submit_video', methods=['GET', 'POST']) +@login_required +@validation_required +def add_video_post(actor): + if current_user.banned: + return show_ban_message() + community = actor_to_community(actor) + + form = CreateVideoForm() + + if g.site.enable_nsfl is False: + form.nsfl.render_kw = {'disabled': True} + if community.nsfw: + form.nsfw.data = True + form.nsfw.render_kw = {'disabled': True} + if community.nsfl: + form.nsfl.data = True + form.nsfw.render_kw = {'disabled': True} + if not(community.is_moderator() or community.is_owner() or current_user.is_admin()): + form.sticky.render_kw = {'disabled': True} + + form.communities.choices = [(c.id, c.display_name()) for c in current_user.communities()] + + if not can_create_post(current_user, community): + abort(401) + + if form.validate_on_submit(): + community = Community.query.get_or_404(form.communities.data) + if not can_create_post(current_user, community): + abort(401) + post = Post(user_id=current_user.id, community_id=form.communities.data, instance_id=1) + save_post(form, post, 'video') + community.post_count += 1 + community.last_active = g.site.last_active = utcnow() + db.session.commit() + post.ap_id = f"https://{current_app.config['SERVER_NAME']}/post/{post.id}" + db.session.commit() + if post.image_id and post.image.file_path is None: + make_image_sizes(post.image_id, 150, 512, 'posts') # the 512 sized image is for masonry view + + # Update list of cross posts + if post.url: + other_posts = Post.query.filter(Post.id != post.id, Post.url == post.url, + Post.posted_at > post.posted_at - timedelta(days=6)).all() + for op in other_posts: + if op.cross_posts is None: + op.cross_posts = [post.id] + else: + op.cross_posts.append(post.id) + if post.cross_posts is None: + post.cross_posts = [op.id] + else: + post.cross_posts.append(op.id) + db.session.commit() + + notify_about_post(post) + + if not community.local_only: + federate_post(community, post) + + return redirect(f"/c/{community.link()}") + else: + form.communities.data = community.id + form.notify_author.data = True + + return render_template('community/add_video_post.html', title=_('Add post to community'), form=form, community=community, + markdown_editor=current_user.markdown_editor, low_bandwidth=False, actor=actor, + moderating_communities=moderating_communities(current_user.get_id()), + joined_communities=joined_communities(current_user.id), + inoculation=inoculation[randint(0, len(inoculation) - 1)] + ) + + def federate_post(community, post): page = { 'type': 'Page', @@ -691,7 +771,7 @@ def federate_post(community, post): "object": page, '@context': default_context() } - if post.type == POST_TYPE_LINK: + if post.type == POST_TYPE_LINK or post.type == POST_TYPE_VIDEO: page['attachment'] = [{'href': post.url, 'type': 'Link'}] elif post.image_id: if post.image.file_path: diff --git a/app/community/util.py b/app/community/util.py index c58dcad9..04326fee 100644 --- a/app/community/util.py +++ b/app/community/util.py @@ -11,7 +11,7 @@ from pillow_heif import register_heif_opener from app import db, cache, celery from app.activitypub.signature import post_request from app.activitypub.util import find_actor_or_create, actor_json_to_model, post_json_to_model, default_context -from app.constants import POST_TYPE_ARTICLE, POST_TYPE_LINK, POST_TYPE_IMAGE +from app.constants import POST_TYPE_ARTICLE, POST_TYPE_LINK, POST_TYPE_IMAGE, POST_TYPE_VIDEO from app.models import Community, File, BannedInstances, PostReply, PostVote, Post, utcnow, CommunityMember, Site, \ Instance, Notification, User, ActivityPubLog from app.utils import get_request, gibberish, markdown_to_html, domain_from_url, allowlist_html, \ @@ -112,7 +112,8 @@ def retrieve_mods_and_backfill(community_id: int): post.ranking = post_ranking(post.score, post.posted_at) if post.url: other_posts = Post.query.filter(Post.id != post.id, Post.url == post.url, - Post.posted_at > post.posted_at - timedelta(days=3), Post.posted_at < post.posted_at + timedelta(days=3)).all() + Post.posted_at > post.posted_at - timedelta(days=3), + Post.posted_at < post.posted_at + timedelta(days=3)).all() for op in other_posts: if op.cross_posts is None: op.cross_posts = [post.id] @@ -223,26 +224,31 @@ def save_post(form, post: Post, type: str): remove_old_file(post.image_id) post.image_id = None - unused, file_extension = os.path.splitext(form.link_url.data) - # this url is a link to an image - turn it into a image post - if file_extension.lower() in allowed_extensions: - file = File(source_url=form.link_url.data) + if post.url.endswith('.mp4') or post.url.endswith('.webm'): + file = File(source_url=form.link_url.data) # make_image_sizes() will take care of turning this into a still image post.image = file db.session.add(file) - post.type = POST_TYPE_IMAGE else: - # check opengraph tags on the page and make a thumbnail if an image is available in the og:image meta tag - opengraph = opengraph_parse(form.link_url.data) - if opengraph and (opengraph.get('og:image', '') != '' or opengraph.get('og:image:url', '') != ''): - filename = opengraph.get('og:image') or opengraph.get('og:image:url') - filename_for_extension = filename.split('?')[0] if '?' in filename else filename - unused, file_extension = os.path.splitext(filename_for_extension) - if file_extension.lower() in allowed_extensions and not filename.startswith('/'): - file = url_to_thumbnail_file(filename) - if file: - file.alt_text = shorten_string(opengraph.get('og:title'), 295) - post.image = file - db.session.add(file) + unused, file_extension = os.path.splitext(form.link_url.data) + # this url is a link to an image - turn it into a image post + if file_extension.lower() in allowed_extensions: + file = File(source_url=form.link_url.data) + post.image = file + db.session.add(file) + post.type = POST_TYPE_IMAGE + else: + # check opengraph tags on the page and make a thumbnail if an image is available in the og:image meta tag + opengraph = opengraph_parse(form.link_url.data) + if opengraph and (opengraph.get('og:image', '') != '' or opengraph.get('og:image:url', '') != ''): + filename = opengraph.get('og:image') or opengraph.get('og:image:url') + filename_for_extension = filename.split('?')[0] if '?' in filename else filename + unused, file_extension = os.path.splitext(filename_for_extension) + if file_extension.lower() in allowed_extensions and not filename.startswith('/'): + file = url_to_thumbnail_file(filename) + if file: + file.alt_text = shorten_string(opengraph.get('og:title'), 295) + post.image = file + db.session.add(file) elif type == 'image': post.title = form.image_title.data @@ -303,11 +309,31 @@ def save_post(form, post: Post, type: str): source_url=final_place.replace('app/static/', f"https://{current_app.config['SERVER_NAME']}/static/")) post.image = file db.session.add(file) + elif type == 'video': + post.title = form.video_title.data + post.body = form.video_body.data + post.body_html = markdown_to_html(post.body) + url_changed = post.id is None or form.video_url.data != post.url + post.url = remove_tracking_from_link(form.video_url.data.strip()) + post.type = POST_TYPE_VIDEO + domain = domain_from_url(form.video_url.data) + domain.post_count += 1 + post.domain = domain + + if url_changed: + if post.image_id: + remove_old_file(post.image_id) + post.image_id = None + + file = File(source_url=form.video_url.data) # make_image_sizes() will take care of turning this into a still image + post.image = file + db.session.add(file) elif type == 'poll': ... else: raise Exception('invalid post type') + if post.id is None: if current_user.reputation > 100: post.up_votes = 1 diff --git a/app/domain/routes.py b/app/domain/routes.py index 874f6118..7141408c 100644 --- a/app/domain/routes.py +++ b/app/domain/routes.py @@ -44,6 +44,7 @@ def show_domain(domain_id): prev_url = url_for('domain.show_domain', domain_id=domain_id, page=posts.prev_num) if posts.has_prev and page != 1 else None return render_template('domain/domain.html', domain=domain, title=domain.name, posts=posts, POST_TYPE_IMAGE=constants.POST_TYPE_IMAGE, POST_TYPE_LINK=constants.POST_TYPE_LINK, + POST_TYPE_VIDEO=constants.POST_TYPE_VIDEO, next_url=next_url, prev_url=prev_url, content_filters=content_filters, moderating_communities=moderating_communities(current_user.get_id()), diff --git a/app/main/routes.py b/app/main/routes.py index f95dd181..2bde90bd 100644 --- a/app/main/routes.py +++ b/app/main/routes.py @@ -12,7 +12,7 @@ from app import db, cache from app.activitypub.util import default_context, make_image_sizes_async, refresh_user_profile, find_actor_or_create, \ refresh_community_profile_task, users_total, active_month, local_posts, local_communities, local_comments from app.constants import SUBSCRIPTION_PENDING, SUBSCRIPTION_MEMBER, POST_TYPE_IMAGE, POST_TYPE_LINK, \ - SUBSCRIPTION_OWNER, SUBSCRIPTION_MODERATOR + SUBSCRIPTION_OWNER, SUBSCRIPTION_MODERATOR, POST_TYPE_VIDEO from app.email import send_email, send_welcome_email from app.inoculation import inoculation from app.main import bp @@ -25,7 +25,8 @@ from sqlalchemy_searchable import search from app.utils import render_template, get_setting, gibberish, request_etag_matches, return_304, blocked_domains, \ ap_datetime, ip_address, retrieve_block_list, shorten_string, markdown_to_text, user_filters_home, \ joined_communities, moderating_communities, parse_page, theme_list, get_request, markdown_to_html, allowlist_html, \ - blocked_instances, communities_banned_from, topic_tree, recently_upvoted_posts, recently_downvoted_posts + blocked_instances, communities_banned_from, topic_tree, recently_upvoted_posts, recently_downvoted_posts, \ + generate_image_from_video_url from app.models import Community, CommunityMember, Post, Site, User, utcnow, Domain, Topic, File, Instance, \ InstanceRole, Notification from PIL import Image @@ -113,7 +114,7 @@ def home_page(type, sort): if sort == 'hot': posts = posts.order_by(desc(Post.ranking)).order_by(desc(Post.posted_at)) elif sort == 'top': - posts = posts.filter(Post.posted_at > utcnow() - timedelta(days=1)).order_by(desc(Post.score)) + posts = posts.filter(Post.posted_at > utcnow() - timedelta(days=1)).order_by(desc(Post.up_votes - Post.down_votes)) elif sort == 'new': posts = posts.order_by(desc(Post.posted_at)) elif sort == 'active': @@ -148,7 +149,7 @@ def home_page(type, sort): recently_downvoted = [] return render_template('index.html', posts=posts, active_communities=active_communities, show_post_community=True, - POST_TYPE_IMAGE=POST_TYPE_IMAGE, POST_TYPE_LINK=POST_TYPE_LINK, + POST_TYPE_IMAGE=POST_TYPE_IMAGE, POST_TYPE_LINK=POST_TYPE_LINK, POST_TYPE_VIDEO=POST_TYPE_VIDEO, low_bandwidth=low_bandwidth, recently_upvoted=recently_upvoted, recently_downvoted=recently_downvoted, SUBSCRIPTION_PENDING=SUBSCRIPTION_PENDING, SUBSCRIPTION_MEMBER=SUBSCRIPTION_MEMBER, diff --git a/app/models.py b/app/models.py index 30f2506a..07913593 100644 --- a/app/models.py +++ b/app/models.py @@ -169,6 +169,28 @@ class ChatMessage(db.Model): sender = db.relationship('User', foreign_keys=[sender_id]) +class Tag(db.Model): + id = db.Column(db.Integer, primary_key=True) + name = db.Column(db.String(256)) + + +class Language(db.Model): + id = db.Column(db.Integer, primary_key=True) + code = db.Column(db.String(5), index=True) + name = db.Column(db.String(50)) + + +community_language = db.Table('community_language', db.Column('community_id', db.Integer, db.ForeignKey('community.id')), + db.Column('language_id', db.Integer, db.ForeignKey('language.id')), + db.PrimaryKeyConstraint('community_id', 'language_id') + ) + +post_tag = db.Table('post_tag', db.Column('post_id', db.Integer, db.ForeignKey('post.id')), + db.Column('tag_id', db.Integer, db.ForeignKey('tag.id')), + db.PrimaryKeyConstraint('post_id', 'tag_id') + ) + + class File(db.Model): id = db.Column(db.Integer, primary_key=True) file_path = db.Column(db.String(255)) @@ -365,6 +387,7 @@ class Community(db.Model): replies = db.relationship('PostReply', lazy='dynamic', cascade="all, delete-orphan") icon = db.relationship('File', foreign_keys=[icon_id], single_parent=True, backref='community', cascade="all, delete-orphan") image = db.relationship('File', foreign_keys=[image_id], single_parent=True, cascade="all, delete-orphan") + languages = db.relationship('Language', lazy='dynamic', secondary=community_language, backref=db.backref('communities', lazy='dynamic')) @cache.memoize(timeout=500) def icon_image(self, size='default') -> str: @@ -838,9 +861,11 @@ class User(UserMixin, db.Model): post.delete_dependencies() post.flush_cache() db.session.delete(post) + db.session.commit() post_replies = PostReply.query.filter_by(user_id=self.id).all() for reply in post_replies: - reply.body = reply.body_html = '' + reply.delete_dependencies() + db.session.delete(reply) db.session.commit() def mention_tag(self): @@ -893,7 +918,9 @@ class Post(db.Model): language = db.Column(db.String(10)) edited_at = db.Column(db.DateTime) reports = db.Column(db.Integer, default=0) # how many times this post has been reported. Set to -1 to ignore reports + language_id = db.Column(db.Integer, index=True) cross_posts = db.Column(MutableList.as_mutable(ARRAY(db.Integer))) + tags = db.relationship('Tag', lazy='dynamic', secondary=post_tag, backref=db.backref('posts', lazy='dynamic')) ap_id = db.Column(db.String(255), index=True) ap_create_id = db.Column(db.String(100)) @@ -1018,6 +1045,10 @@ class PostReply(db.Model): return parent.author.profile_id() def delete_dependencies(self): + for child_reply in self.child_replies(): + child_reply.delete_dependencies() + db.session.delete(child_reply) + db.session.query(Report).filter(Report.suspect_post_reply_id == self.id).delete() db.session.execute(text('DELETE FROM post_reply_vote WHERE post_reply_id = :post_reply_id'), {'post_reply_id': self.id}) @@ -1025,6 +1056,9 @@ class PostReply(db.Model): file = File.query.get(self.image_id) file.delete_from_disk() + def child_replies(self): + return PostReply.query.filter_by(parent_id=self.id).all() + def has_replies(self): reply = PostReply.query.filter_by(parent_id=self.id).first() return reply is not None diff --git a/app/post/routes.py b/app/post/routes.py index c3f72e3d..e5c51dc5 100644 --- a/app/post/routes.py +++ b/app/post/routes.py @@ -13,9 +13,9 @@ from app.activitypub.util import default_context from app.community.util import save_post, send_to_remote_instance from app.inoculation import inoculation from app.post.forms import NewReplyForm, ReportPostForm, MeaCulpaForm -from app.community.forms import CreateLinkForm, CreateImageForm, CreateDiscussionForm +from app.community.forms import CreateLinkForm, CreateImageForm, CreateDiscussionForm, CreateVideoForm from app.post.util import post_replies, get_comment_branch, post_reply_count -from app.constants import SUBSCRIPTION_MEMBER, POST_TYPE_LINK, POST_TYPE_IMAGE, POST_TYPE_ARTICLE +from app.constants import SUBSCRIPTION_MEMBER, POST_TYPE_LINK, POST_TYPE_IMAGE, POST_TYPE_ARTICLE, POST_TYPE_VIDEO from app.models import Post, PostReply, \ PostReplyVote, PostVote, Notification, utcnow, UserBlock, DomainBlock, InstanceBlock, Report, Site, Community, \ Topic, User, Instance @@ -257,6 +257,7 @@ def show_post(post_id: int): canonical=post.ap_id, form=form, replies=replies, THREAD_CUTOFF_DEPTH=constants.THREAD_CUTOFF_DEPTH, description=description, og_image=og_image, POST_TYPE_IMAGE=constants.POST_TYPE_IMAGE, POST_TYPE_LINK=constants.POST_TYPE_LINK, POST_TYPE_ARTICLE=constants.POST_TYPE_ARTICLE, + POST_TYPE_VIDEO=constants.POST_TYPE_VIDEO, autoplay=request.args.get('autoplay', False), noindex=not post.author.indexable, recently_upvoted=recently_upvoted, recently_downvoted=recently_downvoted, recently_upvoted_replies=recently_upvoted_replies, recently_downvoted_replies=recently_downvoted_replies, @@ -303,11 +304,24 @@ def post_vote(post_id: int, vote_direction): if vote_direction == 'upvote': effect = 1 post.up_votes += 1 - post.score += 1 + # Make 'hot' sort more spicy by amplifying the effect of early upvotes + if post.up_votes + post.down_votes <= 10: + post.score += current_app.config['SPICY_UNDER_10'] + elif post.up_votes + post.down_votes <= 30: + post.score += current_app.config['SPICY_UNDER_30'] + elif post.up_votes + post.down_votes <= 60: + post.score += current_app.config['SPICY_UNDER_60'] + else: + post.score += 1 else: effect = -1 post.down_votes += 1 - post.score -= 1 + if post.up_votes + post.down_votes <= 30: + post.score -= current_app.config['SPICY_UNDER_30'] + elif post.up_votes + post.down_votes <= 60: + post.score -= current_app.config['SPICY_UNDER_60'] + else: + post.score -= 1 vote = PostVote(user_id=current_user.id, post_id=post.id, author_id=post.author.id, effect=effect) # upvotes do not increase reputation in low quality communities @@ -693,6 +707,8 @@ def post_edit(post_id: int): return redirect(url_for('post.post_edit_link_post', post_id=post_id)) elif post.type == POST_TYPE_IMAGE: return redirect(url_for('post.post_edit_image_post', post_id=post_id)) + elif post.type == POST_TYPE_VIDEO: + return redirect(url_for('post.post_edit_video_post', post_id=post_id)) else: abort(404) @@ -918,6 +934,87 @@ def post_edit_link_post(post_id: int): abort(401) +@bp.route('/post//edit_video', methods=['GET', 'POST']) +@login_required +def post_edit_video_post(post_id: int): + post = Post.query.get_or_404(post_id) + form = CreateVideoForm() + del form.communities + + mods = post.community.moderators() + if post.community.private_mods: + mod_list = [] + else: + mod_user_ids = [mod.user_id for mod in mods] + mod_list = User.query.filter(User.id.in_(mod_user_ids)).all() + + if post.user_id == current_user.id or post.community.is_moderator() or current_user.is_admin(): + if g.site.enable_nsfl is False: + form.nsfl.render_kw = {'disabled': True} + if post.community.nsfw: + form.nsfw.data = True + form.nsfw.render_kw = {'disabled': True} + if post.community.nsfl: + form.nsfl.data = True + form.nsfw.render_kw = {'disabled': True} + + old_url = post.url + + if form.validate_on_submit(): + save_post(form, post, 'video') + post.community.last_active = utcnow() + post.edited_at = utcnow() + db.session.commit() + + if post.url != old_url: + if post.cross_posts is not None: + old_cross_posts = Post.query.filter(Post.id.in_(post.cross_posts)).all() + post.cross_posts.clear() + for ocp in old_cross_posts: + if ocp.cross_posts is not None: + ocp.cross_posts.remove(post.id) + + new_cross_posts = Post.query.filter(Post.id != post.id, Post.url == post.url, + Post.posted_at > post.edited_at - timedelta(days=6)).all() + for ncp in new_cross_posts: + if ncp.cross_posts is None: + ncp.cross_posts = [post.id] + else: + ncp.cross_posts.append(post.id) + if post.cross_posts is None: + post.cross_posts = [ncp.id] + else: + post.cross_posts.append(ncp.id) + + db.session.commit() + + post.flush_cache() + flash(_('Your changes have been saved.'), 'success') + # federate edit + + if not post.community.local_only: + federate_post_update(post) + + return redirect(url_for('activitypub.post_ap', post_id=post.id)) + else: + form.video_title.data = post.title + form.video_body.data = post.body + form.video_url.data = post.url + form.notify_author.data = post.notify_author + form.nsfw.data = post.nsfw + form.nsfl.data = post.nsfl + form.sticky.data = post.sticky + if not (post.community.is_moderator() or post.community.is_owner() or current_user.is_admin()): + form.sticky.render_kw = {'disabled': True} + return render_template('post/post_edit_video.html', title=_('Edit post'), form=form, post=post, + markdown_editor=current_user.markdown_editor, mods=mod_list, + moderating_communities=moderating_communities(current_user.get_id()), + joined_communities=joined_communities(current_user.get_id()), + inoculation=inoculation[randint(0, len(inoculation) - 1)] + ) + else: + abort(401) + def federate_post_update(post): page_json = { 'type': 'Page', @@ -956,7 +1053,7 @@ def federate_post_update(post): ], 'object': page_json, } - if post.type == POST_TYPE_LINK: + if post.type == POST_TYPE_LINK or post.type == POST_TYPE_VIDEO: page_json['attachment'] = [{'href': post.url, 'type': 'Link'}] elif post.image_id: if post.image.file_path: @@ -1429,7 +1526,7 @@ def post_reply_delete(post_id: int, comment_id: int): post = Post.query.get_or_404(post_id) post_reply = PostReply.query.get_or_404(comment_id) community = post.community - if post_reply.user_id == current_user.id or community.is_moderator(): + if post_reply.user_id == current_user.id or community.is_moderator() or current_user.is_admin(): if post_reply.has_replies(): post_reply.body = 'Deleted by author' if post_reply.author.id == current_user.id else 'Deleted by moderator' post_reply.body_html = markdown_to_html(post_reply.body) diff --git a/app/post/util.py b/app/post/util.py index 99e7f6cb..c7dd5b73 100644 --- a/app/post/util.py +++ b/app/post/util.py @@ -5,7 +5,7 @@ from sqlalchemy import desc, text, or_ from app import db from app.models import PostReply -from app.utils import blocked_instances +from app.utils import blocked_instances, blocked_users # replies to a post, in a tree, sorted by a variety of methods @@ -17,6 +17,9 @@ def post_replies(post_id: int, sort_by: str, show_first: int = 0) -> List[PostRe comments = comments.filter(or_(PostReply.instance_id.not_in(instance_ids), PostReply.instance_id == None)) if current_user.ignore_bots: comments = comments.filter(PostReply.from_bot == False) + blocked_accounts = blocked_users(current_user.id) + if blocked_accounts: + comments = comments.filter(PostReply.user_id.not_in(blocked_accounts)) if sort_by == 'hot': comments = comments.order_by(desc(PostReply.ranking)) elif sort_by == 'top': diff --git a/app/search/routes.py b/app/search/routes.py index 036f34b9..4705e82a 100644 --- a/app/search/routes.py +++ b/app/search/routes.py @@ -6,7 +6,7 @@ from sqlalchemy import or_ from app.models import Post from app.search import bp from app.utils import moderating_communities, joined_communities, render_template, blocked_domains, blocked_instances, \ - communities_banned_from, recently_upvoted_posts, recently_downvoted_posts + communities_banned_from, recently_upvoted_posts, recently_downvoted_posts, blocked_users @bp.route('/search', methods=['GET', 'POST']) @@ -30,6 +30,10 @@ def run_search(): instance_ids = blocked_instances(current_user.id) if instance_ids: posts = posts.filter(or_(Post.instance_id.not_in(instance_ids), Post.instance_id == None)) + # filter blocked users + blocked_accounts = blocked_users(current_user.id) + if blocked_accounts: + posts = posts.filter(Post.user_id.not_in(blocked_accounts)) banned_from = communities_banned_from(current_user.id) if banned_from: posts = posts.filter(Post.community_id.not_in(banned_from)) diff --git a/app/static/structure.css b/app/static/structure.css index 1b3a385b..c70b721a 100644 --- a/app/static/structure.css +++ b/app/static/structure.css @@ -707,6 +707,10 @@ fieldset legend { border-radius: 2px; top: 0; } +.post_list .post_teaser .thumbnail .fe-video { + left: 121px; + top: 0; +} .post_list .post_teaser .thumbnail img { height: 60px; width: 60px; @@ -932,6 +936,11 @@ fieldset legend { border-top: solid 1px #bbb; margin-right: 8px; } +.comments > .comment .comment_body hr { + margin-left: 15px; + margin-right: 15px; + opacity: 0.1; +} .comments > .comment:first-child { border-top: none; padding-top: 0; @@ -985,7 +994,8 @@ fieldset legend { } .voting_buttons_new .upvote_button, .voting_buttons_new .downvote_button { display: inline-block; - padding: 5px 15px; + padding: 5px 0 5px 3px; + text-align: center; position: relative; cursor: pointer; color: rgba(var(--bs-link-color-rgb), var(--bs-link-opacity, 1)); @@ -1012,11 +1022,6 @@ fieldset legend { .voting_buttons_new .upvote_button { top: 1px; } -@media (min-width: 1280px) { - .voting_buttons_new .upvote_button { - padding-right: 5px; - } -} .voting_buttons_new .upvote_button .htmx-indicator { left: 13px; top: 7px; @@ -1026,14 +1031,7 @@ fieldset legend { } .voting_buttons_new .downvote_button .htmx-indicator { left: 12px; -} -@media (min-width: 1280px) { - .voting_buttons_new .downvote_button { - padding-left: 5px; - } - .voting_buttons_new .downvote_button .htmx-indicator { - left: 2px; - } + top: 5px; } .voting_buttons_new .htmx-indicator { position: absolute; @@ -1125,7 +1123,6 @@ fieldset legend { .comment { clear: both; - margin-bottom: 10px; margin-left: 15px; padding-top: 8px; } @@ -1178,7 +1175,7 @@ fieldset legend { } .comment .comment_actions a { text-decoration: none; - padding: 5px 0; + padding: 0; } .comment .comment_actions .hide_button { display: inline-block; @@ -1391,4 +1388,9 @@ h1 .warning_badge { max-width: 100%; } +.responsive-video { + max-width: 100%; + max-height: 90vh; +} + /*# sourceMappingURL=structure.css.map */ diff --git a/app/static/structure.scss b/app/static/structure.scss index 2b2a047f..a4296260 100644 --- a/app/static/structure.scss +++ b/app/static/structure.scss @@ -308,6 +308,11 @@ html { top: 0; } + .fe-video { + left: 121px; + top: 0; + } + img { height: 60px; width: 60px; @@ -552,6 +557,14 @@ html { border-top: solid 1px $grey; margin-right: 8px; + .comment_body { + hr { + margin-left: 15px; + margin-right: 15px; + opacity: 0.1; + } + } + &:first-child { border-top: none; padding-top: 0; @@ -623,7 +636,8 @@ html { .upvote_button, .downvote_button { display: inline-block; - padding: 5px 15px; + padding: 5px 0 5px 3px; + text-align: center; position: relative; cursor: pointer; color: rgba(var(--bs-link-color-rgb),var(--bs-link-opacity,1)); @@ -654,9 +668,7 @@ html { .upvote_button { top: 1px; - @include breakpoint(laptop) { - padding-right: 5px; - } + .htmx-indicator { left: 13px; top: 7px; @@ -667,14 +679,8 @@ html { top: 1px; .htmx-indicator { left: 12px; + top: 5px; } - @include breakpoint(laptop) { - padding-left: 5px; - .htmx-indicator { - left: 2px; - } - } - } .htmx-indicator{ @@ -776,7 +782,6 @@ html { .comment { clear: both; - margin-bottom: 10px; margin-left: 15px; padding-top: 8px; @@ -836,7 +841,7 @@ html { position: relative; a { text-decoration: none; - padding: 5px 0; + padding: 0; } .hide_button { @@ -1057,4 +1062,9 @@ h1 .warning_badge { line-height: initial; max-width: 100%; } +} + +.responsive-video { + max-width: 100%; + max-height: 90vh; } \ No newline at end of file diff --git a/app/static/styles.css b/app/static/styles.css index fc3a6609..6039ec53 100644 --- a/app/static/styles.css +++ b/app/static/styles.css @@ -693,7 +693,7 @@ div.navbar { .comment_actions_link { display: block; position: absolute; - bottom: 0; + top: 3px; right: -16px; width: 41px; text-decoration: none; diff --git a/app/static/styles.scss b/app/static/styles.scss index 77e79e04..9fca4211 100644 --- a/app/static/styles.scss +++ b/app/static/styles.scss @@ -284,7 +284,7 @@ div.navbar { .comment_actions_link { display: block; position: absolute; - bottom: 0; + top: 3px; right: -16px; width: 41px; text-decoration: none; diff --git a/app/templates/admin/_nav.html b/app/templates/admin/_nav.html index 03485e9e..507cd364 100644 --- a/app/templates/admin/_nav.html +++ b/app/templates/admin/_nav.html @@ -1,4 +1,4 @@ -