diff --git a/app/api/alpha/routes.py b/app/api/alpha/routes.py index d3da198e..d552ffa9 100644 --- a/app/api/alpha/routes.py +++ b/app/api/alpha/routes.py @@ -2,7 +2,7 @@ from app.api.alpha import bp from app.api.alpha.utils import get_site, post_site_block, \ get_search, \ get_post_list, get_post, post_post_like, put_post_save, put_post_subscribe, \ - get_reply_list, post_reply_like, put_reply_save, put_reply_subscribe, post_reply, \ + get_reply_list, post_reply_like, put_reply_save, put_reply_subscribe, post_reply, put_reply, \ get_community_list, get_community, post_community_follow, post_community_block, \ get_user, post_user_block from app.shared.auth import log_user_in @@ -218,6 +218,18 @@ def post_alpha_comment(): return jsonify({"error": str(ex)}), 400 +@bp.route('/api/alpha/comment', methods=['PUT']) +def put_alpha_comment(): + if not current_app.debug: + return jsonify({'error': 'alpha api routes only available in debug mode'}) + try: + auth = request.headers.get('Authorization') + data = request.get_json(force=True) or {} + return jsonify(put_reply(auth, data)) + except Exception as ex: + return jsonify({"error": str(ex)}), 400 + + # User @bp.route('/api/alpha/user', methods=['GET']) def get_alpha_user(): @@ -298,7 +310,6 @@ def alpha_post(): # Reply - not yet implemented @bp.route('/api/alpha/comment', methods=['GET']) -@bp.route('/api/alpha/comment', methods=['PUT']) @bp.route('/api/alpha/comment/delete', methods=['POST']) @bp.route('/api/alpha/comment/remove', methods=['POST']) @bp.route('/api/alpha/comment/mark_as_read', methods=['POST']) diff --git a/app/api/alpha/utils/__init__.py b/app/api/alpha/utils/__init__.py index e47baeb3..bae2f1db 100644 --- a/app/api/alpha/utils/__init__.py +++ b/app/api/alpha/utils/__init__.py @@ -1,7 +1,7 @@ from app.api.alpha.utils.site import get_site, post_site_block from app.api.alpha.utils.misc import get_search from app.api.alpha.utils.post import get_post_list, get_post, post_post_like, put_post_save, put_post_subscribe -from app.api.alpha.utils.reply import get_reply_list, post_reply_like, put_reply_save, put_reply_subscribe, post_reply +from app.api.alpha.utils.reply import get_reply_list, post_reply_like, put_reply_save, put_reply_subscribe, post_reply, put_reply from app.api.alpha.utils.community import get_community, get_community_list, post_community_follow, post_community_block from app.api.alpha.utils.user import get_user, post_user_block diff --git a/app/api/alpha/utils/post.py b/app/api/alpha/utils/post.py index 563dd2bc..05f241ec 100644 --- a/app/api/alpha/utils/post.py +++ b/app/api/alpha/utils/post.py @@ -23,6 +23,8 @@ def cached_post_list(type, sort, user_id, community_id, community_name, person_i posts = Post.query.filter_by(deleted=False).join(Community, Community.id == Post.community_id).filter_by(show_all=True) elif type == "Local": posts = Post.query.filter_by(deleted=False).join(Community, Community.id == Post.community_id).filter_by(ap_id=None) + elif type == "Popular": + posts = Post.query.filter_by(deleted=False).join(Community, Community.id == Post.community_id).filter(Community.show_popular == True, Post.score > 100) elif type == "Subscribed" and user_id is not None: posts = Post.query.filter_by(deleted=False).join(CommunityMember, Post.community_id == CommunityMember.community_id).filter_by(is_banned=False, user_id=user_id) else: diff --git a/app/api/alpha/utils/reply.py b/app/api/alpha/utils/reply.py index 0678fc41..e9588975 100644 --- a/app/api/alpha/utils/reply.py +++ b/app/api/alpha/utils/reply.py @@ -2,7 +2,7 @@ from app import cache from app.api.alpha.utils.validators import required, integer_expected, boolean_expected, string_expected from app.api.alpha.views import reply_view from app.models import PostReply, Post -from app.shared.reply import vote_for_reply, bookmark_the_post_reply, remove_the_bookmark_from_post_reply, toggle_post_reply_notification, make_reply +from app.shared.reply import vote_for_reply, bookmark_the_post_reply, remove_the_bookmark_from_post_reply, toggle_post_reply_notification, make_reply, edit_reply from app.utils import authorise_api_user, blocked_users, blocked_instances from sqlalchemy import desc @@ -117,7 +117,7 @@ def put_reply_subscribe(auth, data): return reply_json -def post_reply(auth,data): +def post_reply(auth, data): required(['body', 'post_id'], data) string_expected(['body',], data) integer_expected(['post_id', 'parent_id', 'language_id'], data) @@ -136,3 +136,26 @@ def post_reply(auth,data): reply_json = reply_view(reply=reply, variant=4, user_id=user_id) return reply_json + + +def put_reply(auth, data): + required(['comment_id'], data) + string_expected(['body',], data) + integer_expected(['comment_id', 'language_id'], data) + + reply_id = data['comment_id'] + body = data['body'] if 'body' in data else '' + language_id = data['language_id'] if 'language_id' in data else 2 + + input = {'body': body, 'notify_author': True, 'language_id': language_id} + reply = PostReply.query.get(reply_id) + if not reply: + raise Exception('reply_not_found') + post = Post.query.get(reply.post_id) + if not post: + raise Exception('post_not_found') + + user_id, reply = edit_reply(input, reply, post, SRC_API, auth) + + reply_json = reply_view(reply=reply, variant=4, user_id=user_id) + return reply_json diff --git a/app/api/alpha/views.py b/app/api/alpha/views.py index aefef3d8..45c69ecd 100644 --- a/app/api/alpha/views.py +++ b/app/api/alpha/views.py @@ -280,6 +280,8 @@ def reply_view(reply: PostReply | int, variant, user_id=None, my_vote=0): v1 = {column.name: getattr(reply, column.name) for column in reply.__table__.columns if column.name in include} v1['path'] = calculate_path(reply) + if reply.edited_at: + v1['edited_at'] = reply.edited_at.isoformat() + 'Z' v1.update({'published': reply.posted_at.isoformat() + 'Z', 'ap_id': reply.profile_id(), @@ -287,6 +289,7 @@ def reply_view(reply: PostReply | int, variant, user_id=None, my_vote=0): 'language_id': reply.language_id if reply.language_id else 0, 'removed': reply.deleted, 'distinguished': False}) + return v1 # Variant 2 - views/comment_view.dart - /comment/list api endpoint diff --git a/app/shared/reply.py b/app/shared/reply.py index ced6bb1e..27ee1a99 100644 --- a/app/shared/reply.py +++ b/app/shared/reply.py @@ -26,7 +26,7 @@ def vote_for_reply(reply_id: int, vote_direction, src, auth=None): raise Exception('reply_not_found') user = authorise_api_user(auth, return_type='model') else: - reply = PostReply.query.get_or_404(post_id) + reply = PostReply.query.get_or_404(reply_id) user = current_user undo = reply.vote(user, vote_direction) @@ -241,6 +241,9 @@ def make_reply(input, post, parent_id, src, auth=None): user.language_id = language_id reply.ap_id = reply.profile_id() db.session.commit() + if src == SRC_WEB: + input.body.data = '' + flash('Your comment has been added.') # federation if parent_id: @@ -270,6 +273,10 @@ def make_reply(input, post, parent_id, src, auth=None): 'audience': post.community.public_url(), 'contentMap': { 'en': reply.body_html + }, + 'language': { + 'identifier': reply.language_code(), + 'name': reply.language_name() } } create_json = { @@ -305,7 +312,7 @@ def make_reply(input, post, parent_id, src, auth=None): if not post.community.is_local(): # this is a remote community, send it to the instance that hosts it success = post_request(post.community.ap_inbox_url, create_json, user.private_key, user.public_url() + '#main-key') - if src != SRC_API: + if src == SRC_WEB: if success is False or isinstance(success, str): flash('Failed to send reply', 'error') else: # local community - send it to followers on remote instances @@ -341,3 +348,133 @@ def make_reply(input, post, parent_id, src, auth=None): return user.id, reply else: return reply + + +def edit_reply(input, reply, post, src, auth=None): + if src == SRC_API: + user = authorise_api_user(auth, return_type='model') + content = input['body'] + notify_author = input['notify_author'] + language_id = input['language_id'] + else: + user = current_user + content = input.body.data + notify_author = input.notify_author.data + language_id = input.language_id.data + + reply.body = piefed_markdown_to_lemmy_markdown(content) + reply.body_html = markdown_to_html(content) + reply.notify_author = notify_author + reply.community.last_active = utcnow() + reply.edited_at = utcnow() + reply.language_id = language_id + db.session.commit() + + + if src == SRC_WEB: + flash(_('Your changes have been saved.'), 'success') + + if reply.parent_id: + in_reply_to = PostReply.query.get(reply.parent_id) + else: + in_reply_to = post + + # federate edit + if not post.community.local_only: + reply_json = { + 'type': 'Note', + 'id': reply.public_url(), + 'attributedTo': user.public_url(), + 'to': [ + 'https://www.w3.org/ns/activitystreams#Public' + ], + 'cc': [ + post.community.public_url(), + in_reply_to.author.public_url() + ], + 'content': reply.body_html, + 'inReplyTo': in_reply_to.profile_id(), + 'url': reply.public_url(), + 'mediaType': 'text/html', + 'source': {'content': reply.body, 'mediaType': 'text/markdown'}, + 'published': ap_datetime(reply.posted_at), + 'updated': ap_datetime(reply.edited_at), + 'distinguished': False, + 'audience': post.community.public_url(), + 'contentMap': { + 'en': reply.body_html + }, + 'language': { + 'identifier': reply.language_code(), + 'name': reply.language_name() + } + } + update_json = { + '@context': default_context(), + 'type': 'Update', + 'actor': user.public_url(), + 'audience': post.community.public_url(), + 'to': [ + 'https://www.w3.org/ns/activitystreams#Public' + ], + 'cc': [ + post.community.public_url(), + in_reply_to.author.public_url() + ], + 'object': reply_json, + 'id': f"https://{current_app.config['SERVER_NAME']}/activities/update/{gibberish(15)}" + } + if in_reply_to.notify_author and in_reply_to.author.ap_id is not None: + reply_json['tag'] = [ + { + 'href': in_reply_to.author.public_url(), + 'name': in_reply_to.author.mention_tag(), + 'type': 'Mention' + } + ] + update_json['tag'] = [ + { + 'href': in_reply_to.author.public_url(), + 'name': in_reply_to.author.mention_tag(), + 'type': 'Mention' + } + ] + if not post.community.is_local(): # this is a remote community, send it to the instance that hosts it + success = post_request(post.community.ap_inbox_url, update_json, user.private_key, + user.public_url() + '#main-key') + if src == SRC_WEB: + if success is False or isinstance(success, str): + flash('Failed to send send edit to remote server', 'error') + else: # local community - send it to followers on remote instances + del update_json['@context'] + announce = { + 'id': f"https://{current_app.config['SERVER_NAME']}/activities/announce/{gibberish(15)}", + 'type': 'Announce', + 'to': [ + 'https://www.w3.org/ns/activitystreams#Public' + ], + 'actor': post.community.public_url(), + 'cc': [ + post.community.ap_followers_url + ], + '@context': default_context(), + 'object': update_json + } + + for instance in post.community.following_instances(): + if instance.inbox and not user.has_blocked_instance(instance.id) and not instance_banned(instance.domain): + send_to_remote_instance(instance.id, post.community.id, announce) + + # send copy of Note to post author (who won't otherwise get it if no-one else on their instance is subscribed to the community) + if not in_reply_to.author.is_local() and in_reply_to.author.ap_domain != reply.community.ap_domain: + if not post.community.is_local() or (post.community.is_local and not post.community.has_followers_from_domain(in_reply_to.author.ap_domain)): + success = post_request(in_reply_to.author.ap_inbox_url, update_json, user.private_key, user.public_url() + '#main-key') + if success is False or isinstance(success, str): + # sending to shared inbox is good enough for Mastodon, but Lemmy will reject it the local community has no followers + personal_inbox = in_reply_to.author.public_url() + '/inbox' + post_request(personal_inbox, update_json, user.private_key, user.public_url() + '#main-key') + + if src == SRC_API: + return user.id, reply + else: + return