diff --git a/app/activitypub/util.py b/app/activitypub/util.py index 4337892f..78fa99ee 100644 --- a/app/activitypub/util.py +++ b/app/activitypub/util.py @@ -28,7 +28,8 @@ 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, generate_image_from_video_url, is_video_url, reply_is_stupid + blocked_phrases, microblog_content_to_title, generate_image_from_video_url, is_video_url, reply_is_stupid, \ + notification_subscribers, communities_banned_from def public_key(): @@ -1524,7 +1525,8 @@ def create_post(activity_log: ActivityPubLog, community: Community, request_json post.cross_posts.append(op.id) db.session.commit() - notify_about_post(post) + if post.community_id not in communities_banned_from(user.id): + notify_about_post(post) if user.reputation > 100: post.up_votes += 1 @@ -1537,20 +1539,12 @@ def create_post(activity_log: ActivityPubLog, community: Community, request_json def notify_about_post(post: Post): # todo: eventually this function could trigger a lot of DB activity. This function will need to be a celery task. - # Send notifications based on subscriptions to the author + # Send notifications based on subscriptions notifications_sent_to = set() - for notify_id in post.author.notification_subscribers(): - if notify_id != post.user_id: - new_notification = Notification(title=shorten_string(post.title, 50), url=f"/post/{post.id}", - user_id=notify_id, author_id=post.user_id) - db.session.add(new_notification) - user = User.query.get(notify_id) - user.unread_notifications += 1 - db.session.commit() - notifications_sent_to.add(notify_id) - - # Send notifications based on subscriptions to the community - for notify_id in post.community.notification_subscribers(): + send_notifs_to = set(notification_subscribers(post.author_id, NOTIF_USER) + + notification_subscribers(post.community_id, NOTIF_COMMUNITY) + + notification_subscribers(post.community.topic_id, NOTIF_TOPIC)) + for notify_id in send_notifs_to: if notify_id != post.user_id and notify_id not in notifications_sent_to: new_notification = Notification(title=shorten_string(post.title, 50), url=f"/post/{post.id}", user_id=notify_id, author_id=post.user_id) @@ -1558,6 +1552,7 @@ def notify_about_post(post: Post): user = User.query.get(notify_id) user.unread_notifications += 1 db.session.commit() + notifications_sent_to.add(notify_id) def update_post_reply_from_activity(reply: PostReply, request_json: dict): diff --git a/app/community/routes.py b/app/community/routes.py index 6c7051a3..4b825920 100644 --- a/app/community/routes.py +++ b/app/community/routes.py @@ -497,7 +497,8 @@ def add_discussion_post(actor): db.session.commit() upvote_own_post(post) - notify_about_post(post) + if not post.community.user_is_banned(current_user): + notify_about_post(post) if not community.local_only: federate_post(community, post) diff --git a/app/models.py b/app/models.py index f3f0db7f..5736bf17 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 + SUBSCRIPTION_BANNED, SUBSCRIPTION_PENDING, NOTIF_USER, NOTIF_COMMUNITY, NOTIF_TOPIC # datetime.utcnow() is depreciated in Python 3.12 so it will need to be swapped out eventually @@ -327,6 +327,14 @@ class Topic(db.Model): return_value = list(reversed(return_value)) return '/'.join(return_value) + def notify_new_posts(self, user_id: int) -> bool: + existing_notification = NotificationSubscription.query.filter(NotificationSubscription.entity_id == self.id, + NotificationSubscription.user_id == user_id, + NotificationSubscription.type == NOTIF_TOPIC).first() + return existing_notification is not None + + + class Community(db.Model): query_class = FullTextSearchQuery @@ -476,14 +484,9 @@ class Community(db.Model): return False def user_is_banned(self, user): - membership = CommunityMember.query.filter(CommunityMember.community_id == self.id, CommunityMember.user_id == user.id).first() - if membership and membership.is_banned: - return True - banned = CommunityBan.query.filter(CommunityBan.community_id == self.id, CommunityBan.user_id == user.id).first() - if banned: - return True - return False - + # use communities_banned_from() instead of this method, where possible. Redis caches the result of communities_banned_from() + community_bans = CommunityBan.query.filter(CommunityBan.user_id == user.id).all() + return self.id in [cb.community_id for cb in community_bans] def profile_id(self): retval = self.ap_profile_id if self.ap_profile_id else f"https://{current_app.config['SERVER_NAME']}/c/{self.name}" diff --git a/app/templates/topic/_notification_toggle.html b/app/templates/topic/_notification_toggle.html new file mode 100644 index 00000000..c39105cb --- /dev/null +++ b/app/templates/topic/_notification_toggle.html @@ -0,0 +1,5 @@ + \ No newline at end of file diff --git a/app/templates/topic/show_topic.html b/app/templates/topic/show_topic.html index 9531c9a1..de8761fd 100644 --- a/app/templates/topic/show_topic.html +++ b/app/templates/topic/show_topic.html @@ -18,6 +18,9 @@

{{ topic.name }} + {% if current_user.is_authenticated %} + {% include 'topic/_notification_toggle.html' %} + {% endif %}

{% if sub_topics %}
{{ _('Sub-topics') }}
diff --git a/app/topic/routes.py b/app/topic/routes.py index a3f16806..9ad33bcb 100644 --- a/app/topic/routes.py +++ b/app/topic/routes.py @@ -9,9 +9,11 @@ from flask_babel import _ from sqlalchemy import text, desc, or_ from app.activitypub.signature import post_request -from app.constants import SUBSCRIPTION_NONMEMBER, SUBSCRIPTION_OWNER, SUBSCRIPTION_MODERATOR, POST_TYPE_IMAGE, POST_TYPE_LINK, POST_TYPE_VIDEO +from app.constants import SUBSCRIPTION_NONMEMBER, SUBSCRIPTION_OWNER, SUBSCRIPTION_MODERATOR, POST_TYPE_IMAGE, \ + POST_TYPE_LINK, POST_TYPE_VIDEO, NOTIF_TOPIC from app.inoculation import inoculation -from app.models import Topic, Community, Post, utcnow, CommunityMember, CommunityJoinRequest, User +from app.models import Topic, Community, Post, utcnow, CommunityMember, CommunityJoinRequest, User, \ + NotificationSubscription from app.topic import bp from app import db, celery, cache from app.topic.forms import ChooseTopicsForm @@ -206,6 +208,26 @@ def topic_create_post(topic_name): SUBSCRIPTION_OWNER=SUBSCRIPTION_OWNER, SUBSCRIPTION_MODERATOR=SUBSCRIPTION_MODERATOR) +@bp.route('/topic//notification', methods=['GET', 'POST']) +@login_required +def topic_notification(topic_id: int): + # Toggle whether the current user is subscribed to notifications about this community's posts or not + topic = Topic.query.get_or_404(topic_id) + existing_notification = NotificationSubscription.query.filter(NotificationSubscription.entity_id == topic.id, + NotificationSubscription.user_id == current_user.id, + NotificationSubscription.type == NOTIF_TOPIC).first() + if existing_notification: + db.session.delete(existing_notification) + db.session.commit() + else: # no subscription yet, so make one + new_notification = NotificationSubscription(name=topic.name, user_id=current_user.id, entity_id=topic.id, + type=NOTIF_TOPIC) + db.session.add(new_notification) + db.session.commit() + + return render_template('topic/_notification_toggle.html', topic=topic) + + def topics_for_form(): topics = Topic.query.filter_by(parent_id=None).order_by(Topic.name).all() result = [] diff --git a/app/utils.py b/app/utils.py index e33f0548..e5e52fe9 100644 --- a/app/utils.py +++ b/app/utils.py @@ -331,7 +331,7 @@ def community_membership(user: User, community: Community) -> int: @cache.memoize(timeout=86400) -def communities_banned_from(user_id) -> List[int]: +def communities_banned_from(user_id: int) -> List[int]: community_bans = CommunityBan.query.filter(CommunityBan.user_id == user_id).all() return [cb.community_id for cb in community_bans] @@ -710,6 +710,11 @@ 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()) + + # topics, in a tree def topic_tree() -> List: topics = Topic.query.order_by(Topic.name)