anyone can subscribe to any post or comment #20

(not just the author)
This commit is contained in:
rimu 2024-04-29 21:43:37 +12:00
parent cede0163fd
commit 01d4b2678c
13 changed files with 177 additions and 52 deletions

View file

@ -1364,17 +1364,10 @@ def create_post_reply(activity_log: ActivityPubLog, community: Community, in_rep
db.session.commit() db.session.commit()
# send notification to the post/comment being replied to # 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: if parent_comment_id:
anchor = f"comment_{post_reply.id}" notify_about_post_reply(parent_comment, post_reply)
notification = Notification(title=shorten_string(_('Reply from %(name)s on %(post_title)s', else:
name=post_reply.author.display_name(), notify_about_post_reply(None, post_reply)
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 user.reputation > 100: if user.reputation > 100:
post_reply.up_votes += 1 post_reply.up_votes += 1
@ -1555,6 +1548,35 @@ def notify_about_post(post: Post):
notifications_sent_to.add(notify_id) 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): def update_post_reply_from_activity(reply: PostReply, request_json: dict):
if 'source' in request_json['object'] and \ if 'source' in request_json['object'] and \
isinstance(request_json['object']['source'], dict) and \ isinstance(request_json['object']['source'], dict) and \

View file

@ -16,11 +16,12 @@ import os
from app.activitypub.signature import RsaKeys from app.activitypub.signature import RsaKeys
from app.auth.util import random_token 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.email import send_verification_email, send_email
from app.models import Settings, BannedInstances, Interest, Role, User, RolePermission, Domain, ActivityPubLog, \ from app.models import Settings, BannedInstances, Interest, Role, User, RolePermission, Domain, ActivityPubLog, \
utcnow, Site, Instance, File, Notification, Post, CommunityMember, NotificationSubscription 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 from app.utils import file_get_contents, retrieve_block_list, blocked_domains, retrieve_peertube_block_list, \
shorten_string
def register(app): def register(app):
@ -318,6 +319,29 @@ def register(app):
db.session.commit() db.session.commit()
print('Done') 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): def parse_communities(interests_source, segment):
lines = interests_source.split("\n") lines = interests_source.split("\n")

View file

@ -497,8 +497,8 @@ def add_discussion_post(actor):
db.session.commit() db.session.commit()
upvote_own_post(post) 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: if not community.local_only:
federate_post(community, post) federate_post(community, post)
@ -1178,7 +1178,8 @@ def community_notification(community_id: int):
db.session.commit() db.session.commit()
else: # no subscription yet, so make one else: # no subscription yet, so make one
if community.id not in communities_banned_from(current_user.id): 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) type=NOTIF_COMMUNITY)
db.session.add(new_notification) db.session.add(new_notification)
db.session.commit() db.session.commit()

View file

@ -11,9 +11,9 @@ from pillow_heif import register_heif_opener
from app import db, cache, celery from app import db, cache, celery
from app.activitypub.signature import post_request 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.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, \ 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, \ 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, \ is_image_url, ensure_directory_exists, inbox_domain, post_ranking, shorten_string, parse_page, \
remove_tracking_from_link, ap_datetime, instance_banned, blocked_phrases remove_tracking_from_link, ap_datetime, instance_banned, blocked_phrases
@ -387,8 +387,24 @@ def save_post(form, post: Post, type: str):
return return
db.session.add(post) 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() g.site.last_active = utcnow()
db.session.commit()
def delete_post_from_community(post_id): def delete_post_from_community(post_id):

View file

@ -19,7 +19,7 @@ import jwt
import os import os
from app.constants import SUBSCRIPTION_NONMEMBER, SUBSCRIPTION_MEMBER, SUBSCRIPTION_MODERATOR, SUBSCRIPTION_OWNER, \ 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 # 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 name
return False 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): class PostReply(db.Model):
query_class = FullTextSearchQuery query_class = FullTextSearchQuery
@ -1086,6 +1092,12 @@ class PostReply(db.Model):
return name return name
return False 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): class Domain(db.Model):
id = db.Column(db.Integer, primary_key=True) id = db.Column(db.Integer, primary_key=True)

View file

@ -9,17 +9,18 @@ from sqlalchemy import or_, desc
from app import db, constants, cache from app import db, constants, cache
from app.activitypub.signature import HttpSignature, post_request 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.community.util import save_post, send_to_remote_instance
from app.inoculation import inoculation from app.inoculation import inoculation
from app.post.forms import NewReplyForm, ReportPostForm, MeaCulpaForm from app.post.forms import NewReplyForm, ReportPostForm, MeaCulpaForm
from app.community.forms import CreateLinkForm, CreateImageForm, CreateDiscussionForm, CreateVideoForm from app.community.forms import CreateLinkForm, CreateImageForm, CreateDiscussionForm, CreateVideoForm
from app.post.util import post_replies, get_comment_branch, post_reply_count 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, \ from app.constants import SUBSCRIPTION_MEMBER, SUBSCRIPTION_OWNER, SUBSCRIPTION_MODERATOR, POST_TYPE_LINK, \
POST_TYPE_ARTICLE, POST_TYPE_VIDEO POST_TYPE_IMAGE, \
POST_TYPE_ARTICLE, POST_TYPE_VIDEO, NOTIF_REPLY, NOTIF_POST
from app.models import Post, PostReply, \ from app.models import Post, PostReply, \
PostReplyVote, PostVote, Notification, utcnow, UserBlock, DomainBlock, InstanceBlock, Report, Site, Community, \ PostReplyVote, PostVote, Notification, utcnow, UserBlock, DomainBlock, InstanceBlock, Report, Site, Community, \
Topic, User, Instance Topic, User, Instance, NotificationSubscription
from app.post import bp from app.post import bp
from app.utils import get_setting, render_template, allowlist_html, markdown_to_html, validation_required, \ 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, \ 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, body_html=markdown_to_html(form.body.data), body_html_safe=True,
from_bot=current_user.bot, nsfw=post.nsfw, nsfl=post.nsfl, from_bot=current_user.bot, nsfw=post.nsfw, nsfl=post.nsfl,
notify_author=form.notify_author.data, instance_id=1) 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.last_active = community.last_active = utcnow()
post.reply_count += 1 post.reply_count += 1
community.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.add(reply)
db.session.commit() 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 # upvote own reply
reply.score = 1 reply.score = 1
reply.up_votes = 1 reply.up_votes = 1
@ -622,16 +627,19 @@ def add_reply(post_id: int, comment_id: int):
if blocked_phrase in reply.body: if blocked_phrase in reply.body:
abort(401) abort(401)
db.session.add(reply) 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() 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 # upvote own reply
reply.score = 1 reply.score = 1
reply.up_votes = 1 reply.up_votes = 1
@ -1663,20 +1671,43 @@ def post_reply_delete(post_id: int, comment_id: int):
@bp.route('/post/<int:post_id>/notification', methods=['GET', 'POST']) @bp.route('/post/<int:post_id>/notification', methods=['GET', 'POST'])
@login_required @login_required
def post_notification(post_id: int): 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) post = Post.query.get_or_404(post_id)
if post.user_id == current_user.id: existing_notification = NotificationSubscription.query.filter(NotificationSubscription.entity_id == post.id,
post.notify_author = not post.notify_author NotificationSubscription.user_id == current_user.id,
NotificationSubscription.type == NOTIF_POST).first()
if existing_notification:
db.session.delete(existing_notification)
db.session.commit() 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) return render_template('post/_post_notification_toggle.html', post=post)
@bp.route('/post_reply/<int:post_reply_id>/notification', methods=['GET', 'POST']) @bp.route('/post_reply/<int:post_reply_id>/notification', methods=['GET', 'POST'])
@login_required @login_required
def post_reply_notification(post_reply_id: int): 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) post_reply = PostReply.query.get_or_404(post_reply_id)
if post_reply.user_id == current_user.id: existing_notification = NotificationSubscription.query.filter(NotificationSubscription.entity_id == post_reply.id,
post_reply.notify_author = not post_reply.notify_author NotificationSubscription.user_id == current_user.id,
NotificationSubscription.type == NOTIF_REPLY).first()
if existing_notification:
db.session.delete(existing_notification)
db.session.commit() 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}) return render_template('post/_reply_notification_toggle.html', comment={'comment': post_reply})

View file

@ -699,6 +699,15 @@ div.navbar {
text-decoration: none; text-decoration: none;
} }
.notif_toggle {
display: block;
position: absolute;
top: 2px;
right: 30px;
width: 41px;
text-decoration: none;
}
.alert { .alert {
width: 96%; width: 96%;
} }

View file

@ -290,6 +290,15 @@ div.navbar {
text-decoration: none; text-decoration: none;
} }
.notif_toggle {
display: block;
position: absolute;
top: 2px;
right: 30px;
width: 41px;
text-decoration: none;
}
.alert { .alert {
width: 96%; width: 96%;
} }

View file

@ -14,7 +14,7 @@
{% include "post/_post_voting_buttons.html" %} {% include "post/_post_voting_buttons.html" %}
</div> </div>
<h1 class="mt-2 post_title">{{ post.title }} <h1 class="mt-2 post_title">{{ 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' %} {% include 'post/_post_notification_toggle.html' %}
{% endif %} {% endif %}
{% if post.nsfw %}<span class="warning_badge nsfw" title="{{ _('Not safe for work') }}">nsfw</span>{% endif %} {% if post.nsfw %}<span class="warning_badge nsfw" title="{{ _('Not safe for work') }}">nsfw</span>{% endif %}

View file

@ -1,4 +1,5 @@
<a href="{{ url_for('post.post_notification', post_id=post.id) }}" rel="nofollow" <a href="{{ url_for('post.post_notification', post_id=post.id) }}" rel="nofollow" aria-live="assertive"
class="small fe {{ 'fe-bell' if post.notify_author else 'fe-no-bell' }} no-underline" aria-label="{{ 'Notify about replies to this post.' if post.notify_new_replies(current_user.id) else 'Do not notify about new replies to this post.' }}"
class="small fe {{ 'fe-bell' if post.notify_new_replies(current_user.id) else 'fe-no-bell' }} no-underline"
hx-post="{{ url_for('post.post_notification', post_id=post.id) }}" hx-trigger="click throttle:1s" hx-swap="outerHTML" hx-post="{{ url_for('post.post_notification', post_id=post.id) }}" hx-trigger="click throttle:1s" hx-swap="outerHTML"
title="{{ _('Notify about replies') }}" aria-label="{{ _('Notify about replies') }}"></a> title="{{ _('Notify about replies') }}" aria-label="{{ _('Notify about replies') }}"></a>

View file

@ -1,4 +1,4 @@
<a href="{{ url_for('post.post_reply_notification', post_reply_id=comment['comment'].id) }}" rel="nofollow" <a href="{{ url_for('post.post_reply_notification', post_reply_id=comment['comment'].id) }}" rel="nofollow"
class="notif_toggle fe {{ 'fe-bell' if comment['comment'].notify_author else 'fe-no-bell' }}" class="notif_toggle fe {{ 'fe-bell' if comment['comment'].notify_new_replies(current_user.id) else 'fe-no-bell' }}"
hx-post="{{ url_for('post.post_reply_notification', post_reply_id=comment['comment'].id) }}" hx-trigger="click throttle:1s" hx-swap="outerHTML" hx-post="{{ url_for('post.post_reply_notification', post_reply_id=comment['comment'].id) }}" hx-trigger="click throttle:1s" hx-swap="outerHTML"
title="{{ _('Notify about replies') }}" aria-label="{{ _('Notify about replies') }}"></a> title="{{ _('Notify about replies') }}" aria-label="{{ _('Notify about replies') }}"></a>

View file

@ -128,7 +128,7 @@
<a href='#'><span class="fe fe-collapse"></span></a> <a href='#'><span class="fe fe-collapse"></span></a>
{% endif %} {% endif %}
</div> </div>
{% 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" %} {% include "post/_reply_notification_toggle.html" %}
{% endif %} {% endif %}
<a href="{{ url_for('post.post_reply_options', post_id=post.id, comment_id=comment['comment'].id) }}" class="comment_actions_link" rel="nofollow noindex" aria-label="{{ _('Comment options') }}"><span class="fe fe-options" title="Options"> </span></a> <a href="{{ url_for('post.post_reply_options', post_id=post.id, comment_id=comment['comment'].id) }}" class="comment_actions_link" rel="nofollow noindex" aria-label="{{ _('Comment options') }}"><span class="fe fe-options" title="Options"> </span></a>

View file

@ -710,9 +710,9 @@ def finalize_user_setup(user, application_required=False):
send_welcome_email(user, application_required) send_welcome_email(user, application_required)
def notification_subscribers(entity_id, entity_type) -> List[int]: 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 = :community_id AND type = :type '), return list(db.session.execute(text('SELECT user_id FROM "notification_subscription" WHERE entity_id = :entity_id AND type = :type '),
{'community_id': entity_id, 'type': entity_type}).scalars()) {'entity_id': entity_id, 'type': entity_type}).scalars())
# topics, in a tree # topics, in a tree