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()
# 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 \

View file

@ -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")

View file

@ -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()

View file

@ -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):

View file

@ -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)

View file

@ -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/<int:post_id>/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/<int:post_reply_id>/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})

View file

@ -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%;
}

View file

@ -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%;
}

View file

@ -14,7 +14,7 @@
{% include "post/_post_voting_buttons.html" %}
</div>
<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' %}
{% 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"
class="small fe {{ 'fe-bell' if post.notify_author else 'fe-no-bell' }} no-underline"
<a href="{{ url_for('post.post_notification', post_id=post.id) }}" rel="nofollow" aria-live="assertive"
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"
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"
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"
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>
{% endif %}
</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" %}
{% 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>

View file

@ -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