diff --git a/app/activitypub/util.py b/app/activitypub/util.py index 81c61be3..9e211071 100644 --- a/app/activitypub/util.py +++ b/app/activitypub/util.py @@ -7,6 +7,7 @@ from typing import Union, Tuple from flask import current_app, request, g, url_for from sqlalchemy import text from app import db, cache, constants, celery +from app.community.util import notify_about_post from app.models import User, Post, Community, BannedInstances, File, PostReply, AllowedInstances, Instance, utcnow, \ PostVote, PostReplyVote, ActivityPubLog, Notification, Site import time @@ -1040,6 +1041,9 @@ def create_post(activity_log: ActivityPubLog, community: Community, request_json if post.image_id: make_image_sizes(post.image_id, 266, None, 'posts') + + notify_about_post(post) + if user.reputation > 100: vote = PostVote(user_id=1, author_id=post.user_id, post_id=post.id, diff --git a/app/community/routes.py b/app/community/routes.py index 08a37ad4..4e3be6f1 100644 --- a/app/community/routes.py +++ b/app/community/routes.py @@ -9,7 +9,8 @@ from app.activitypub.util import default_context from app.community.forms import SearchRemoteCommunity, AddLocalCommunity, CreatePostForm, ReportCommunityForm, \ DeleteCommunityForm from app.community.util import search_for_community, community_url_exists, actor_to_community, \ - opengraph_parse, url_to_thumbnail_file, save_post, save_icon_file, save_banner_file, send_to_remote_instance + opengraph_parse, url_to_thumbnail_file, save_post, save_icon_file, save_banner_file, send_to_remote_instance, \ + notify_about_post from app.constants import SUBSCRIPTION_MEMBER, SUBSCRIPTION_OWNER, POST_TYPE_LINK, POST_TYPE_ARTICLE, POST_TYPE_IMAGE, \ SUBSCRIPTION_PENDING from app.models import User, Community, CommunityMember, CommunityJoinRequest, CommunityBan, Post, \ @@ -223,13 +224,13 @@ def subscribe(actor): success = post_request(community.ap_inbox_url, follow, current_user.private_key, current_user.profile_id() + '#main-key') if success: - flash('Your request to join has been sent to ' + community.title) + flash(_('You have joined %(name)s', name=community.title)) else: - flash('There was a problem while trying to join.', 'error') + flash(_('There was a problem while trying to join.'), 'error') else: # for local communities, joining is instant banned = CommunityBan.query.filter_by(user_id=current_user.id, community_id=community.id).first() if banned: - flash('You cannot join this community') + flash(_('You cannot join this community')) else: member = CommunityMember(user_id=current_user.id, community_id=community.id) db.session.add(member) @@ -340,6 +341,8 @@ def add_post(actor): post.ap_id = f"https://{current_app.config['SERVER_NAME']}/post/{post.id}" db.session.commit() + notify_about_post(post) + page = { 'type': 'Page', 'id': post.ap_id, @@ -486,3 +489,22 @@ def community_block_instance(community_id: int): db.session.commit() flash(_('Content from %(name)s will be hidden.', name=community.instance.domain)) return redirect(community.local_url()) + + +@bp.route('//notification', methods=['GET', 'POST']) +@login_required +def community_notification(community_id: int): + community = Community.query.get_or_404(community_id) + member_info = CommunityMember.query.filter(CommunityMember.community_id == community.id, + CommunityMember.user_id == current_user.id).first() + # existing community members get their notification flag toggled + if member_info and not member_info.is_banned: + member_info.notify_new_posts = not member_info.notify_new_posts + db.session.commit() + else: # people who are not yet members become members, with notify on. + if not community.user_is_banned(current_user): + new_member = CommunityMember(community_id=community.id, user_id=current_user.id, notify_new_posts=True) + db.session.add(new_member) + db.session.commit() + + return render_template('community/_notification_toggle.html', community=community) diff --git a/app/community/util.py b/app/community/util.py index 5905f31e..9f3371d1 100644 --- a/app/community/util.py +++ b/app/community/util.py @@ -13,9 +13,9 @@ from app.activitypub.signature import post_request from app.activitypub.util import find_actor_or_create, actor_json_to_model, post_json_to_model from app.constants import POST_TYPE_ARTICLE, POST_TYPE_LINK, POST_TYPE_IMAGE from app.models import Community, File, BannedInstances, PostReply, PostVote, Post, utcnow, CommunityMember, Site, \ - Instance + Instance, Notification, User from app.utils import get_request, gibberish, markdown_to_html, domain_from_url, validate_image, allowlist_html, \ - html_to_markdown, is_image_url, ensure_directory_exists, inbox_domain, post_ranking + html_to_markdown, is_image_url, ensure_directory_exists, inbox_domain, post_ranking, shorten_string from sqlalchemy import desc, text import os from opengraph_parse import parse_page @@ -387,4 +387,15 @@ def send_to_remote_instance_task(instance_id: int, community_id: int, payload): instance.start_trying_again = utcnow() + timedelta(seconds=instance.failures ** 4) if instance.failures > 2: instance.dormant = True - db.session.commit() \ No newline at end of file + db.session.commit() + + +def notify_about_post(post: Post): + people_to_notify = CommunityMember.query.filter_by(community_id=post.community_id, notify_new_posts=True, is_banned=False) + for person in people_to_notify: + if person.user_id != post.user_id: + new_notification = Notification(title=shorten_string(post.title, 25), url=f"/post/{post.id}", user_id=person.user_id, author_id=post.user_id) + db.session.add(new_notification) + user = User.query.get(person.user_id) # todo: make this more efficient by doing a join with CommunityMember at the start of the function + user.unread_notifications += 1 + db.session.commit() diff --git a/app/models.py b/app/models.py index cd86a358..6d6bd9e5 100644 --- a/app/models.py +++ b/app/models.py @@ -253,6 +253,11 @@ class Community(db.Model): else: return any(moderator.user_id == user.id and moderator.is_owner for moderator in self.moderators()) + def user_is_banned(self, user): + membership = CommunityMember.query.filter(CommunityMember.community_id == self.id, CommunityMember.user_id == user.id).first() + return membership.is_banned if membership else False + + def profile_id(self): return self.ap_profile_id if self.ap_profile_id else f"https://{current_app.config['SERVER_NAME']}/c/{self.name}" @@ -265,6 +270,14 @@ class Community(db.Model): else: return f"https://{current_app.config['SERVER_NAME']}/c/{self.ap_id}" + def notify_new_posts(self, user_id: int) -> bool: + member_info = CommunityMember.query.filter(CommunityMember.community_id == self.id, + CommunityMember.is_banned == False, + CommunityMember.user_id == user_id).first() + if not member_info: + return False + return member_info.notify_new_posts + # instances that have users which are members of this community. (excluding the current instance) def following_instances(self, include_dormant=False) -> List[Instance]: instances = Instance.query.join(User, User.instance_id == Instance.id).join(CommunityMember, CommunityMember.user_id == User.id) @@ -782,6 +795,7 @@ class CommunityMember(db.Model): is_moderator = db.Column(db.Boolean, default=False) is_owner = db.Column(db.Boolean, default=False) is_banned = db.Column(db.Boolean, default=False) + notify_new_posts = db.Column(db.Boolean, default=False) created_at = db.Column(db.DateTime, default=utcnow) diff --git a/app/static/scss/_typography.scss b/app/static/scss/_typography.scss index 8ec6a69c..f0be9d1e 100644 --- a/app/static/scss/_typography.scss +++ b/app/static/scss/_typography.scss @@ -198,7 +198,7 @@ content: "\e967"; } -.fe-bell { +.fe-bell, .fe-no-bell { position: relative; top: 1px; } @@ -206,6 +206,18 @@ content: "\e91e"; } +.fe-no-bell::before { + content: "\e91f"; +} + +h1 { + .fe-bell, .fe-no-bell { + font-size: 18px; + top: -5px; + left: 17px; + } +} + .fe-magnify { position: relative; top: 1px; diff --git a/app/static/structure.css b/app/static/structure.css index 10ecea11..50377bcd 100644 --- a/app/static/structure.css +++ b/app/static/structure.css @@ -225,7 +225,7 @@ nav, etc which are used site-wide */ content: "\e967"; } -.fe-bell { +.fe-bell, .fe-no-bell { position: relative; top: 1px; } @@ -234,6 +234,16 @@ nav, etc which are used site-wide */ content: "\e91e"; } +.fe-no-bell::before { + content: "\e91f"; +} + +h1 .fe-bell, h1 .fe-no-bell { + font-size: 18px; + top: -5px; + left: 17px; +} + .fe-magnify { position: relative; top: 1px; diff --git a/app/static/styles.css b/app/static/styles.css index a1fa418a..a8b02614 100644 --- a/app/static/styles.css +++ b/app/static/styles.css @@ -224,7 +224,7 @@ content: "\e967"; } -.fe-bell { +.fe-bell, .fe-no-bell { position: relative; top: 1px; } @@ -233,6 +233,16 @@ content: "\e91e"; } +.fe-no-bell::before { + content: "\e91f"; +} + +h1 .fe-bell, h1 .fe-no-bell { + font-size: 18px; + top: -5px; + left: 17px; +} + .fe-magnify { position: relative; top: 1px; diff --git a/app/templates/community/_notification_toggle.html b/app/templates/community/_notification_toggle.html new file mode 100644 index 00000000..42bfe40e --- /dev/null +++ b/app/templates/community/_notification_toggle.html @@ -0,0 +1,4 @@ + \ No newline at end of file diff --git a/app/templates/community/community.html b/app/templates/community/community.html index 0e73defe..230e3329 100644 --- a/app/templates/community/community.html +++ b/app/templates/community/community.html @@ -15,7 +15,11 @@ -

{{ community.title }}

+

{{ community.title }} + {% if current_user.is_authenticated %} + {% include 'community/_notification_toggle.html' %} + {% endif %} +

{% elif community.icon_id %}
-

{{ community.title }}

+

{{ community.title }} + {% if current_user.is_authenticated %} + {% include 'community/_notification_toggle.html' %} + {% endif %} +

{% else %} @@ -40,7 +48,11 @@ -

{{ community.title }}

+

{{ community.title }} + {% if current_user.is_authenticated %} + {% include 'community/_notification_toggle.html' %} + {% endif %} +

{% endif %} {% include "community/_community_nav.html" %}
diff --git a/migrations/versions/dc49309fc13e_community_notifications.py b/migrations/versions/dc49309fc13e_community_notifications.py new file mode 100644 index 00000000..61d2bbd4 --- /dev/null +++ b/migrations/versions/dc49309fc13e_community_notifications.py @@ -0,0 +1,32 @@ +"""community notifications + +Revision ID: dc49309fc13e +Revises: fd5d3a9cb584 +Create Date: 2024-01-07 10:35:55.484246 + +""" +from alembic import op +import sqlalchemy as sa + + +# revision identifiers, used by Alembic. +revision = 'dc49309fc13e' +down_revision = 'fd5d3a9cb584' +branch_labels = None +depends_on = None + + +def upgrade(): + # ### commands auto generated by Alembic - please adjust! ### + with op.batch_alter_table('community_member', schema=None) as batch_op: + batch_op.add_column(sa.Column('notify_new_posts', sa.Boolean(), nullable=True)) + + # ### end Alembic commands ### + + +def downgrade(): + # ### commands auto generated by Alembic - please adjust! ### + with op.batch_alter_table('community_member', schema=None) as batch_op: + batch_op.drop_column('notify_new_posts') + + # ### end Alembic commands ###