From 01d4b2678c5cf894b1d1c1ccf200a6442395af03 Mon Sep 17 00:00:00 2001 From: rimu <3310831+rimu@users.noreply.github.com> Date: Mon, 29 Apr 2024 21:43:37 +1200 Subject: [PATCH] anyone can subscribe to any post or comment #20 (not just the author) --- app/activitypub/util.py | 44 ++++++++--- app/cli.py | 30 ++++++- app/community/routes.py | 7 +- app/community/util.py | 20 ++++- app/models.py | 14 +++- app/post/routes.py | 79 +++++++++++++------ app/static/styles.css | 9 +++ app/static/styles.scss | 9 +++ app/templates/post/_post_full.html | 2 +- .../post/_post_notification_toggle.html | 5 +- .../post/_reply_notification_toggle.html | 2 +- app/templates/post/post.html | 2 +- app/utils.py | 6 +- 13 files changed, 177 insertions(+), 52 deletions(-) diff --git a/app/activitypub/util.py b/app/activitypub/util.py index 78fa99ee..5957afad 100644 --- a/app/activitypub/util.py +++ b/app/activitypub/util.py @@ -1364,17 +1364,10 @@ def create_post_reply(activity_log: ActivityPubLog, community: Community, in_rep db.session.commit() # send notification to the post/comment being replied to - if notification_target.notify_author and post_reply.user_id != notification_target.user_id and notification_target.author.ap_id is None: - anchor = f"comment_{post_reply.id}" - notification = Notification(title=shorten_string(_('Reply from %(name)s on %(post_title)s', - name=post_reply.author.display_name(), - post_title=post.title), 50), - user_id=notification_target.user_id, - author_id=post_reply.user_id, - url=url_for('activitypub.post_ap', post_id=post.id, _anchor=anchor)) - db.session.add(notification) - notification_target.author.unread_notifications += 1 - db.session.commit() + if parent_comment_id: + notify_about_post_reply(parent_comment, post_reply) + else: + notify_about_post_reply(None, post_reply) if user.reputation > 100: post_reply.up_votes += 1 @@ -1555,6 +1548,35 @@ def notify_about_post(post: Post): notifications_sent_to.add(notify_id) +def notify_about_post_reply(parent_reply: Union[PostReply, None], new_reply: PostReply): + + if parent_reply is None: # This happens when a new_reply is a top-level comment, not a comment on a comment + send_notifs_to = notification_subscribers(new_reply.post.id, NOTIF_POST) + for notify_id in send_notifs_to: + if new_reply.user_id != notify_id: + new_notification = Notification(title=shorten_string(_('Reply to %(post_title)s', + post_title=new_reply.post.title), 50), + url=f"/post/{new_reply.post.id}#comment_{new_reply.id}", + user_id=notify_id, author_id=new_reply.user_id) + db.session.add(new_notification) + user = User.query.get(notify_id) + user.unread_notifications += 1 + db.session.commit() + else: + # Send notifications based on subscriptions + send_notifs_to = set(notification_subscribers(parent_reply.id, NOTIF_REPLY)) + for notify_id in send_notifs_to: + if new_reply.user_id != notify_id: + new_notification = Notification(title=shorten_string(_('Reply to comment on %(post_title)s', + post_title=parent_reply.post.title), 50), + url=f"/post/{parent_reply.post.id}#comment_{new_reply.id}", + user_id=notify_id, author_id=new_reply.user_id) + db.session.add(new_notification) + user = User.query.get(notify_id) + user.unread_notifications += 1 + db.session.commit() + + def update_post_reply_from_activity(reply: PostReply, request_json: dict): if 'source' in request_json['object'] and \ isinstance(request_json['object']['source'], dict) and \ diff --git a/app/cli.py b/app/cli.py index 7791f2bd..612114e8 100644 --- a/app/cli.py +++ b/app/cli.py @@ -16,11 +16,12 @@ import os from app.activitypub.signature import RsaKeys from app.auth.util import random_token -from app.constants import NOTIF_COMMUNITY +from app.constants import NOTIF_COMMUNITY, NOTIF_POST, NOTIF_REPLY from app.email import send_verification_email, send_email from app.models import Settings, BannedInstances, Interest, Role, User, RolePermission, Domain, ActivityPubLog, \ - utcnow, Site, Instance, File, Notification, Post, CommunityMember, NotificationSubscription -from app.utils import file_get_contents, retrieve_block_list, blocked_domains, retrieve_peertube_block_list + utcnow, Site, Instance, File, Notification, Post, CommunityMember, NotificationSubscription, PostReply +from app.utils import file_get_contents, retrieve_block_list, blocked_domains, retrieve_peertube_block_list, \ + shorten_string def register(app): @@ -318,6 +319,29 @@ def register(app): db.session.commit() print('Done') + @app.cli.command("migrate_post_notifs") + def migrate_post_notifs(): + with app.app_context(): + posts = Post.query.filter(Post.notify_author == True).all() + for post in posts: + new_notification = NotificationSubscription(name=shorten_string(_('Replies to my post %(post_title)s', + post_title=post.title)), + user_id=post.user_id, entity_id=post.id, + type=NOTIF_POST) + db.session.add(new_notification) + db.session.commit() + + post_replies = PostReply.query.filter(PostReply.notify_author == True).all() + for reply in post_replies: + new_notification = NotificationSubscription(name=shorten_string(_('Replies to my comment on %(post_title)s', + post_title=reply.post.title)), + user_id=post.user_id, entity_id=reply.id, + type=NOTIF_REPLY) + db.session.add(new_notification) + db.session.commit() + + print('Done') + def parse_communities(interests_source, segment): lines = interests_source.split("\n") diff --git a/app/community/routes.py b/app/community/routes.py index a307b604..9a8f7fbc 100644 --- a/app/community/routes.py +++ b/app/community/routes.py @@ -497,8 +497,8 @@ def add_discussion_post(actor): db.session.commit() upvote_own_post(post) - if not post.community.user_is_banned(current_user): - notify_about_post(post) + + notify_about_post(post) if not community.local_only: federate_post(community, post) @@ -1178,7 +1178,8 @@ def community_notification(community_id: int): db.session.commit() else: # no subscription yet, so make one if community.id not in communities_banned_from(current_user.id): - new_notification = NotificationSubscription(name=community.title, user_id=current_user.id, entity_id=community.id, + new_notification = NotificationSubscription(name=shorten_string(_('New posts in %(community_name)s', community_name=community.title)), + user_id=current_user.id, entity_id=community.id, type=NOTIF_COMMUNITY) db.session.add(new_notification) db.session.commit() diff --git a/app/community/util.py b/app/community/util.py index 2babba51..44755acc 100644 --- a/app/community/util.py +++ b/app/community/util.py @@ -11,9 +11,9 @@ 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, ensure_domains_match -from app.constants import POST_TYPE_ARTICLE, POST_TYPE_LINK, POST_TYPE_IMAGE, POST_TYPE_VIDEO +from app.constants import POST_TYPE_ARTICLE, POST_TYPE_LINK, POST_TYPE_IMAGE, POST_TYPE_VIDEO, NOTIF_POST from app.models import Community, File, BannedInstances, PostReply, PostVote, Post, utcnow, CommunityMember, Site, \ - Instance, Notification, User, ActivityPubLog + Instance, Notification, User, ActivityPubLog, NotificationSubscription from app.utils import get_request, gibberish, markdown_to_html, domain_from_url, allowlist_html, \ is_image_url, ensure_directory_exists, inbox_domain, post_ranking, shorten_string, parse_page, \ remove_tracking_from_link, ap_datetime, instance_banned, blocked_phrases @@ -387,8 +387,24 @@ def save_post(form, post: Post, type: str): return db.session.add(post) + db.session.commit() + + # Notify author about replies + # Remove any subscription that currently exists + existing_notification = NotificationSubscription.query.filter(NotificationSubscription.entity_id == post.id, + NotificationSubscription.user_id == current_user.id, + NotificationSubscription.type == NOTIF_POST).first() + if existing_notification: + db.session.delete(existing_notification) + + # Add subscription if necessary + if form.notify_author.data: + new_notification = NotificationSubscription(name=post.title, user_id=current_user.id, entity_id=post.id, + type=NOTIF_POST) + db.session.add(new_notification) g.site.last_active = utcnow() + db.session.commit() def delete_post_from_community(post_id): diff --git a/app/models.py b/app/models.py index 61f219e6..c7ac0ee6 100644 --- a/app/models.py +++ b/app/models.py @@ -19,7 +19,7 @@ import jwt import os from app.constants import SUBSCRIPTION_NONMEMBER, SUBSCRIPTION_MEMBER, SUBSCRIPTION_MODERATOR, SUBSCRIPTION_OWNER, \ - SUBSCRIPTION_BANNED, SUBSCRIPTION_PENDING, NOTIF_USER, NOTIF_COMMUNITY, NOTIF_TOPIC + SUBSCRIPTION_BANNED, SUBSCRIPTION_PENDING, NOTIF_USER, NOTIF_COMMUNITY, NOTIF_TOPIC, NOTIF_POST, NOTIF_REPLY # datetime.utcnow() is depreciated in Python 3.12 so it will need to be swapped out eventually @@ -990,6 +990,12 @@ class Post(db.Model): return name return False + def notify_new_replies(self, user_id: int) -> bool: + existing_notification = NotificationSubscription.query.filter(NotificationSubscription.entity_id == self.id, + NotificationSubscription.user_id == user_id, + NotificationSubscription.type == NOTIF_POST).first() + return existing_notification is not None + class PostReply(db.Model): query_class = FullTextSearchQuery @@ -1086,6 +1092,12 @@ class PostReply(db.Model): return name return False + def notify_new_replies(self, user_id: int) -> bool: + existing_notification = NotificationSubscription.query.filter(NotificationSubscription.entity_id == self.id, + NotificationSubscription.user_id == user_id, + NotificationSubscription.type == NOTIF_REPLY).first() + return existing_notification is not None + class Domain(db.Model): id = db.Column(db.Integer, primary_key=True) diff --git a/app/post/routes.py b/app/post/routes.py index 9334b99f..26cd558b 100644 --- a/app/post/routes.py +++ b/app/post/routes.py @@ -9,17 +9,18 @@ from sqlalchemy import or_, desc from app import db, constants, cache from app.activitypub.signature import HttpSignature, post_request -from app.activitypub.util import default_context +from app.activitypub.util import default_context, notify_about_post_reply 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, CreateVideoForm from app.post.util import post_replies, get_comment_branch, post_reply_count -from app.constants import SUBSCRIPTION_MEMBER, SUBSCRIPTION_OWNER, SUBSCRIPTION_MODERATOR, POST_TYPE_LINK, POST_TYPE_IMAGE, \ - POST_TYPE_ARTICLE, POST_TYPE_VIDEO +from app.constants import SUBSCRIPTION_MEMBER, SUBSCRIPTION_OWNER, SUBSCRIPTION_MODERATOR, POST_TYPE_LINK, \ + POST_TYPE_IMAGE, \ + POST_TYPE_ARTICLE, POST_TYPE_VIDEO, NOTIF_REPLY, NOTIF_POST from app.models import Post, PostReply, \ PostReplyVote, PostVote, Notification, utcnow, UserBlock, DomainBlock, InstanceBlock, Report, Site, Community, \ - Topic, User, Instance + Topic, User, Instance, NotificationSubscription from app.post import bp from app.utils import get_setting, render_template, allowlist_html, markdown_to_html, validation_required, \ shorten_string, markdown_to_text, gibberish, ap_datetime, return_304, \ @@ -98,14 +99,7 @@ def show_post(post_id: int): body_html=markdown_to_html(form.body.data), body_html_safe=True, from_bot=current_user.bot, nsfw=post.nsfw, nsfl=post.nsfl, notify_author=form.notify_author.data, instance_id=1) - if post.notify_author and current_user.id != post.user_id: - notification = Notification(title=shorten_string(_('Reply from %(name)s on %(post_title)s', - name=current_user.display_name(), - post_title=post.title), 50), - user_id=post.user_id, - author_id=current_user.id, url=url_for('activitypub.post_ap', post_id=post.id)) - db.session.add(notification) - post.author.unread_notifications += 1 + post.last_active = community.last_active = utcnow() post.reply_count += 1 community.post_reply_count += 1 @@ -113,6 +107,17 @@ def show_post(post_id: int): db.session.add(reply) db.session.commit() + notify_about_post_reply(None, reply) + + # Subscribe to own comment + if form.notify_author.data: + new_notification = NotificationSubscription(name=shorten_string(_('Replies to my comment on %(post_title)s', + post_title=post.title), 50), + user_id=current_user.id, entity_id=reply.id, + type=NOTIF_REPLY) + db.session.add(new_notification) + db.session.commit() + # upvote own reply reply.score = 1 reply.up_votes = 1 @@ -622,16 +627,19 @@ def add_reply(post_id: int, comment_id: int): if blocked_phrase in reply.body: abort(401) db.session.add(reply) - if in_reply_to.notify_author and current_user.id != in_reply_to.user_id and in_reply_to.author.ap_id is None: # todo: check if replier is blocked - notification = Notification(title=shorten_string(_('Reply from %(name)s on %(post_title)s', - name=current_user.display_name(), - post_title=post.title), 50), - user_id=in_reply_to.user_id, - author_id=current_user.id, url=url_for('activitypub.post_ap', post_id=post.id)) - db.session.add(notification) - in_reply_to.author.unread_notifications += 1 db.session.commit() + # Notify subscribers + notify_about_post_reply(in_reply_to, reply) + + # Subscribe to own comment + if form.notify_author.data: + new_notification = NotificationSubscription(name=shorten_string(_('Replies to my comment on %(post_title)s', + post_title=post.title), 50), + user_id=current_user.id, entity_id=reply.id, + type=NOTIF_REPLY) + db.session.add(new_notification) + # upvote own reply reply.score = 1 reply.up_votes = 1 @@ -1663,20 +1671,43 @@ def post_reply_delete(post_id: int, comment_id: int): @bp.route('/post//notification', methods=['GET', 'POST']) @login_required def post_notification(post_id: int): + # Toggle whether the current user is subscribed to notifications about top-level replies to this post or not post = Post.query.get_or_404(post_id) - if post.user_id == current_user.id: - post.notify_author = not post.notify_author + existing_notification = NotificationSubscription.query.filter(NotificationSubscription.entity_id == post.id, + NotificationSubscription.user_id == current_user.id, + NotificationSubscription.type == NOTIF_POST).first() + if existing_notification: + db.session.delete(existing_notification) db.session.commit() + else: # no subscription yet, so make one + new_notification = NotificationSubscription(name=shorten_string(_('Replies to my post %(post_title)s', + post_title=post.title)), + user_id=current_user.id, entity_id=post.id, + type=NOTIF_POST) + db.session.add(new_notification) + db.session.commit() + return render_template('post/_post_notification_toggle.html', post=post) @bp.route('/post_reply//notification', methods=['GET', 'POST']) @login_required def post_reply_notification(post_reply_id: int): + # Toggle whether the current user is subscribed to notifications about replies to this reply or not post_reply = PostReply.query.get_or_404(post_reply_id) - if post_reply.user_id == current_user.id: - post_reply.notify_author = not post_reply.notify_author + existing_notification = NotificationSubscription.query.filter(NotificationSubscription.entity_id == post_reply.id, + NotificationSubscription.user_id == current_user.id, + NotificationSubscription.type == NOTIF_REPLY).first() + if existing_notification: + db.session.delete(existing_notification) db.session.commit() + else: # no subscription yet, so make one + new_notification = NotificationSubscription(name=shorten_string(_('Replies to my comment on %(post_title)s', + post_title=post_reply.post.title)), user_id=current_user.id, entity_id=post_reply.id, + type=NOTIF_REPLY) + db.session.add(new_notification) + db.session.commit() + return render_template('post/_reply_notification_toggle.html', comment={'comment': post_reply}) diff --git a/app/static/styles.css b/app/static/styles.css index a4944c07..274a5bf8 100644 --- a/app/static/styles.css +++ b/app/static/styles.css @@ -699,6 +699,15 @@ div.navbar { text-decoration: none; } +.notif_toggle { + display: block; + position: absolute; + top: 2px; + right: 30px; + width: 41px; + text-decoration: none; +} + .alert { width: 96%; } diff --git a/app/static/styles.scss b/app/static/styles.scss index 4e7f1eca..dd3750af 100644 --- a/app/static/styles.scss +++ b/app/static/styles.scss @@ -290,6 +290,15 @@ div.navbar { text-decoration: none; } +.notif_toggle { + display: block; + position: absolute; + top: 2px; + right: 30px; + width: 41px; + text-decoration: none; +} + .alert { width: 96%; } diff --git a/app/templates/post/_post_full.html b/app/templates/post/_post_full.html index 1304a4e5..7b0dc184 100644 --- a/app/templates/post/_post_full.html +++ b/app/templates/post/_post_full.html @@ -14,7 +14,7 @@ {% include "post/_post_voting_buttons.html" %}

{{ post.title }} - {% if current_user.is_authenticated and post.user_id == current_user.id %} + {% if current_user.is_authenticated %} {% include 'post/_post_notification_toggle.html' %} {% endif %} {% if post.nsfw %}nsfw{% endif %} diff --git a/app/templates/post/_post_notification_toggle.html b/app/templates/post/_post_notification_toggle.html index 3063c78e..cf553d42 100644 --- a/app/templates/post/_post_notification_toggle.html +++ b/app/templates/post/_post_notification_toggle.html @@ -1,4 +1,5 @@ - \ No newline at end of file diff --git a/app/templates/post/_reply_notification_toggle.html b/app/templates/post/_reply_notification_toggle.html index 17082fb8..4a47771f 100644 --- a/app/templates/post/_reply_notification_toggle.html +++ b/app/templates/post/_reply_notification_toggle.html @@ -1,4 +1,4 @@ \ No newline at end of file diff --git a/app/templates/post/post.html b/app/templates/post/post.html index 8227d604..f856a12b 100644 --- a/app/templates/post/post.html +++ b/app/templates/post/post.html @@ -128,7 +128,7 @@ {% endif %} - {% if current_user.is_authenticated and current_user.verified and current_user.id == comment['comment'].author.id %} + {% if current_user.is_authenticated and current_user.verified %} {% include "post/_reply_notification_toggle.html" %} {% endif %} diff --git a/app/utils.py b/app/utils.py index e5e52fe9..a8a522e8 100644 --- a/app/utils.py +++ b/app/utils.py @@ -710,9 +710,9 @@ def finalize_user_setup(user, application_required=False): send_welcome_email(user, application_required) -def notification_subscribers(entity_id, entity_type) -> List[int]: - return list(db.session.execute(text('SELECT user_id FROM "notification_subscription" WHERE entity_id = :community_id AND type = :type '), - {'community_id': entity_id, 'type': entity_type}).scalars()) +def notification_subscribers(entity_id: int, entity_type: int) -> List[int]: + return list(db.session.execute(text('SELECT user_id FROM "notification_subscription" WHERE entity_id = :entity_id AND type = :type '), + {'entity_id': entity_id, 'type': entity_type}).scalars()) # topics, in a tree