From 5752b8eaebea325332e3b6d1c47f25a0567c0835 Mon Sep 17 00:00:00 2001 From: rimu <3310831+rimu@users.noreply.github.com> Date: Thu, 30 Nov 2023 23:21:37 +1300 Subject: [PATCH] finish notifications --- app/community/forms.py | 2 +- app/models.py | 4 + app/post/forms.py | 3 +- app/post/routes.py | 18 ++- app/static/scss/_typography.scss | 4 + app/static/structure.css | 25 ++++- app/static/structure.scss | 29 ++++- app/static/styles.css | 4 + app/templates/base.html | 4 +- app/templates/post/add_reply.html | 4 +- app/templates/post/post.html | 2 +- app/templates/user/notifications.html | 105 ++++++++++++++++++ app/user/routes.py | 67 ++++++++++- ...tions.py => cae2e31293e8_notifications.py} | 12 +- 14 files changed, 261 insertions(+), 22 deletions(-) create mode 100644 app/templates/user/notifications.html rename migrations/versions/{f5706aa6b94c_notifications.py => cae2e31293e8_notifications.py} (81%) diff --git a/app/community/forms.py b/app/community/forms.py index 89a6b537..c4c18162 100644 --- a/app/community/forms.py +++ b/app/community/forms.py @@ -44,7 +44,7 @@ class CreatePostForm(FlaskForm): # flair = SelectField(_l('Flair'), coerce=int) nsfw = BooleanField(_l('NSFW')) nsfl = BooleanField(_l('NSFL')) - notify_author = BooleanField(_l('Send me post reply notifications')) + notify_author = BooleanField(_l('Notify about replies')) submit = SubmitField(_l('Post')) def validate(self, extra_validators=None) -> bool: diff --git a/app/models.py b/app/models.py index 92dfc65e..f83d9fd0 100644 --- a/app/models.py +++ b/app/models.py @@ -180,6 +180,7 @@ class User(UserMixin, db.Model): indexable = db.Column(db.Boolean, default=False) bot = db.Column(db.Boolean, default=False) ignore_bots = db.Column(db.Boolean, default=False) + unread_notifications = db.Column(db.Integer, default=0) avatar = db.relationship('File', foreign_keys=[avatar_id], single_parent=True, cascade="all, delete-orphan") cover = db.relationship('File', foreign_keys=[cover_id], single_parent=True, cascade="all, delete-orphan") @@ -312,6 +313,9 @@ class User(UserMixin, db.Model): return User.query.get(id) def purge_content(self): + files = File.query.join(Post).filter(Post.user_id == self.id).all() + for file in files: + file.delete_from_disk() db.session.query(ActivityLog).filter(ActivityLog.user_id == self.id).delete() db.session.query(PostVote).filter(PostVote.user_id == self.id).delete() db.session.query(PostReplyVote).filter(PostReplyVote.user_id == self.id).delete() diff --git a/app/post/forms.py b/app/post/forms.py index 35d2e85e..1a21decb 100644 --- a/app/post/forms.py +++ b/app/post/forms.py @@ -1,9 +1,10 @@ from flask_wtf import FlaskForm -from wtforms import TextAreaField, SubmitField +from wtforms import TextAreaField, SubmitField, BooleanField from wtforms.validators import DataRequired, Length from flask_babel import _, lazy_gettext as _l class NewReplyForm(FlaskForm): body = TextAreaField(_l('Body'), render_kw={'placeholder': 'What are your thoughts?', 'rows': 3}, validators={DataRequired(), Length(min=3, max=5000)}) + notify_author = BooleanField(_l('Notify about replies')) submit = SubmitField(_l('Comment')) \ No newline at end of file diff --git a/app/post/routes.py b/app/post/routes.py index 9f7f1a6d..acc61dc2 100644 --- a/app/post/routes.py +++ b/app/post/routes.py @@ -12,7 +12,7 @@ from app.community.forms import CreatePostForm from app.post.util import post_replies, get_comment_branch, post_reply_count from app.constants import SUBSCRIPTION_MEMBER, SUBSCRIPTION_OWNER, POST_TYPE_LINK, POST_TYPE_ARTICLE, POST_TYPE_IMAGE from app.models import Post, PostReply, \ - PostReplyVote, PostVote + PostReplyVote, PostVote, Notification 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, domain_from_url, validate_image, gibberish @@ -27,7 +27,12 @@ def show_post(post_id: int): if current_user.is_authenticated and current_user.verified and form.validate_on_submit(): reply = PostReply(user_id=current_user.id, post_id=post.id, community_id=post.community.id, body=form.body.data, body_html=markdown_to_html(form.body.data), body_html_safe=True, - from_bot=current_user.bot, up_votes=1, nsfw=post.nsfw, nsfl=post.nsfl) + from_bot=current_user.bot, up_votes=1, nsfw=post.nsfw, nsfl=post.nsfl, + notify_author=form.notify_author.data) + if post.notify_author and current_user.id != post.user_id: # todo: check if replier is blocked + notification = Notification(title=_('Reply: ') + shorten_string(form.body.data), user_id=post.user_id, + author_id=current_user.id, url=url_for('post.show_post', post_id=post.id)) + db.session.add(notification) db.session.add(reply) db.session.commit() reply_vote = PostReplyVote(user_id=current_user.id, author_id=current_user.id, post_reply_id=reply.id, @@ -42,6 +47,7 @@ def show_post(post_id: int): post_id=post_id)) # redirect to current page to avoid refresh resubmitting the form else: replies = post_replies(post.id, 'top') + form.notify_author.data = True og_image = post.image.source_url if post.image_id else None description = shorten_string(markdown_to_text(post.body), 150) if post.body else None @@ -178,8 +184,13 @@ def add_reply(post_id: int, comment_id: int): reply = PostReply(user_id=current_user.id, post_id=post.id, parent_id=comment.id, depth=comment.depth + 1, community_id=post.community.id, body=form.body.data, body_html=markdown_to_html(form.body.data), body_html_safe=True, - from_bot=current_user.bot, up_votes=1, nsfw=post.nsfw, nsfl=post.nsfl) + from_bot=current_user.bot, up_votes=1, nsfw=post.nsfw, nsfl=post.nsfl, + notify_author=form.notify_author.data) db.session.add(reply) + if comment.notify_author and current_user.id != comment.user_id: # todo: check if replier is blocked + notification = Notification(title=_('Reply: ') + shorten_string(form.body.data), user_id=comment.user_id, + author_id=current_user.id, url=url_for('post.show_post', post_id=post.id)) + db.session.add(notification) db.session.commit() reply_vote = PostReplyVote(user_id=current_user.id, author_id=current_user.id, post_reply_id=reply.id, effect=1.0) @@ -195,6 +206,7 @@ def add_reply(post_id: int, comment_id: int): else: return redirect(url_for('post.continue_discussion', post_id=post_id, comment_id=reply.parent_id)) else: + form.notify_author.data = True return render_template('post/add_reply.html', title=_('Discussing %(title)s', title=post.title), post=post, is_moderator=is_moderator, form=form, comment=comment) diff --git a/app/static/scss/_typography.scss b/app/static/scss/_typography.scss index fbf9d08e..b9a49587 100644 --- a/app/static/scss/_typography.scss +++ b/app/static/scss/_typography.scss @@ -182,6 +182,10 @@ content: "\e967"; } +.fe-bell::before { + content: "\e91e"; +} + .fe-image { position: relative; top: 2px; diff --git a/app/static/structure.css b/app/static/structure.css index e44bf55b..db796065 100644 --- a/app/static/structure.css +++ b/app/static/structure.css @@ -185,6 +185,10 @@ nav, etc which are used site-wide */ content: "\e967"; } +.fe-bell::before { + content: "\e91e"; +} + .fe-image { position: relative; top: 2px; @@ -403,6 +407,23 @@ fieldset legend { .post_reply_form label { display: none; } +.post_reply_form .form-check { + position: absolute; + bottom: -14px; + left: 122px; +} +.post_reply_form .form-check label { + display: inherit; +} + +.add_reply .form-control-label { + display: none; +} +.add_reply .form-check { + position: absolute; + bottom: -14px; + left: 122px; +} .post_list .post_teaser { border-bottom: solid 2px #ddd; @@ -548,8 +569,8 @@ fieldset legend { border-top: solid 1px #ddd; } -.add_reply .form-control-label { - display: none; +.table tr th { + vertical-align: middle; } /*# sourceMappingURL=structure.css.map */ diff --git a/app/static/structure.scss b/app/static/structure.scss index 5af70785..891e6df3 100644 --- a/app/static/structure.scss +++ b/app/static/structure.scss @@ -115,7 +115,30 @@ nav, etc which are used site-wide */ label { display: none; } + + .form-check { + position: absolute; + bottom: -14px; + left: 122px; + + label { + display: inherit; + } + } } + +.add_reply { + .form-control-label { + display: none; + } + + .form-check { + position: absolute; + bottom: -14px; + left: 122px; + } +} + .post_list { .post_teaser { @@ -294,8 +317,8 @@ nav, etc which are used site-wide */ } } -.add_reply { - .form-control-label { - display: none; +.table { + tr th { + vertical-align: middle; } } \ No newline at end of file diff --git a/app/static/styles.css b/app/static/styles.css index cfbb71a7..ccd1645a 100644 --- a/app/static/styles.css +++ b/app/static/styles.css @@ -184,6 +184,10 @@ content: "\e967"; } +.fe-bell::before { + content: "\e91e"; +} + .fe-image { position: relative; top: 2px; diff --git a/app/templates/base.html b/app/templates/base.html index 5c980ce1..f2941a8f 100644 --- a/app/templates/base.html +++ b/app/templates/base.html @@ -71,11 +71,13 @@ {% else %} - + {% if user_access('change instance settings', current_user.id) %} {% endif %} + {% endif %} diff --git a/app/templates/post/add_reply.html b/app/templates/post/add_reply.html index 00e19274..0a762aeb 100644 --- a/app/templates/post/add_reply.html +++ b/app/templates/post/add_reply.html @@ -12,7 +12,9 @@
Comment you are replying to {{ comment.body_html | safe}}
- {{ render_form(form) }} +
+ {{ render_form(form) }} +
diff --git a/app/templates/post/post.html b/app/templates/post/post.html index b613cd6e..4a41dc13 100644 --- a/app/templates/post/post.html +++ b/app/templates/post/post.html @@ -14,7 +14,7 @@

-
+
{{ render_form(form) }}
diff --git a/app/templates/user/notifications.html b/app/templates/user/notifications.html new file mode 100644 index 00000000..2f2b9014 --- /dev/null +++ b/app/templates/user/notifications.html @@ -0,0 +1,105 @@ +{% extends "base.html" %} +{% from 'bootstrap/form.html' import render_form %} + +{% block app_content %} +
+
+ +

{{ _('Notifications') }}

+ + {% if notifications %} + + + + + + + {% for notification in notifications %} + + + + + + {% endfor %} +
NotificationWhenMark all read
{% if not notification.read %}{% endif %} + {{ notification.title }} + {% if not notification.read %}{% endif %} + {{ moment(notification.created_at).fromNow(refresh=True) }} + Delete +
+ {% else %} +

No notifications to show.

+ {% endif %} +
+ +
+ +
+
+

{{ _('Manage') }}

+
+ +
+ + {% if len(subscribed) > 0 or len(moderates) > 0 %} +
+
+

{{ _('Communities') }}

+
+
+ {% if len(subscribed) > 0 %} +

Subscribed to

+ + {% endif %} + {% if len(moderates) > 0 %} +

Moderates

+ + {% endif %} +
+
+ {% endif %} + {% if upvoted %} +
+
+

{{ _('Upvoted') }}

+
+
+ + +
+
+ {% endif %} +
+
+{% endblock %} diff --git a/app/user/routes.py b/app/user/routes.py index ce74d099..e4ff2ab5 100644 --- a/app/user/routes.py +++ b/app/user/routes.py @@ -1,13 +1,15 @@ +from datetime import datetime, timedelta + from flask import redirect, url_for, flash, request, make_response, session, Markup, current_app, abort from flask_login import login_user, logout_user, current_user, login_required from flask_babel import _ from app import db -from app.models import Post, Community, CommunityMember, User, PostReply, PostVote +from app.models import Post, Community, CommunityMember, User, PostReply, PostVote, Notification from app.user import bp from app.user.forms import ProfileForm, SettingsForm from app.utils import get_setting, render_template, markdown_to_html, user_access, markdown_to_text, shorten_string -from sqlalchemy import desc, or_ +from sqlalchemy import desc, or_, text def show_profile(user): @@ -105,7 +107,7 @@ def ban_profile(actor): abort(404) if user.id == current_user.id: - flash('You cannot ban yourself.', 'error') + flash(_('You cannot ban yourself.'), 'error') else: user.banned = True db.session.commit() @@ -130,7 +132,7 @@ def unban_profile(actor): abort(404) if user.id == current_user.id: - flash('You cannot unban yourself.', 'error') + flash(_('You cannot unban yourself.'), 'error') else: user.banned = False db.session.commit() @@ -154,7 +156,7 @@ def delete_profile(actor): if user is None: abort(404) if user.id == current_user.id: - flash('You cannot delete yourself.', 'error') + flash(_('You cannot delete yourself.'), 'error') else: user.banned = True user.deleted = True @@ -180,7 +182,7 @@ def ban_purge_profile(actor): abort(404) if user.id == current_user.id: - flash('You cannot purge yourself.', 'error') + flash(_('You cannot purge yourself.'), 'error') else: user.banned = True user.deleted = True @@ -199,3 +201,56 @@ def ban_purge_profile(actor): goto = request.args.get('redirect') if 'redirect' in request.args else f'/u/{actor}' return redirect(goto) + + +@bp.route('/notifications', methods=['GET', 'POST']) +@login_required +def notifications(): + """Remove notifications older than 30 days""" + db.session.query(Notification).filter( + Notification.created_at < datetime.utcnow() - timedelta(days=30)).delete() + db.session.commit() + + # Update unread notifications count + current_user.unread_notifications = Notification.query.filter_by(user_id=current_user.id, read=False).count() + db.session.commit() + + notification_list = Notification.query.filter_by(user_id=current_user.id).order_by(desc(Notification.created_at)).all() + + return render_template('user/notifications.html', title=_('Notifications'), notifications=notification_list, user=current_user) + + +@bp.route('/notification//goto', methods=['GET', 'POST']) +@login_required +def notification_goto(notification_id): + notification = Notification.query.get_or_404(notification_id) + if notification.user_id == current_user.id: + if not notification.read: + current_user.unread_notifications -= 1 + notification.read = True + db.session.commit() + return redirect(notification.url) + else: + abort(403) + + +@bp.route('/notification//delete', methods=['GET', 'POST']) +@login_required +def notification_delete(notification_id): + notification = Notification.query.get_or_404(notification_id) + if notification.user_id == current_user.id: + if not notification.read: + current_user.unread_notifications -= 1 + db.session.delete(notification) + db.session.commit() + return redirect(url_for('user.notifications')) + + +@bp.route('/notifications/all_read', methods=['GET', 'POST']) +@login_required +def notifications_all_read(): + db.session.execute(text('UPDATE notification SET read=true WHERE user_id = :user_id'), {'user_id': current_user.id}) + current_user.unread_notifications = 0 + db.session.commit() + flash(_('All notifications marked as read.')) + return redirect(url_for('user.notifications')) diff --git a/migrations/versions/f5706aa6b94c_notifications.py b/migrations/versions/cae2e31293e8_notifications.py similarity index 81% rename from migrations/versions/f5706aa6b94c_notifications.py rename to migrations/versions/cae2e31293e8_notifications.py index 57f1d0af..f206e313 100644 --- a/migrations/versions/f5706aa6b94c_notifications.py +++ b/migrations/versions/cae2e31293e8_notifications.py @@ -1,8 +1,8 @@ """notifications -Revision ID: f5706aa6b94c +Revision ID: cae2e31293e8 Revises: ee45b9ea4a0c -Create Date: 2023-11-30 19:21:04.549875 +Create Date: 2023-11-30 22:49:24.942158 """ from alembic import op @@ -10,7 +10,7 @@ import sqlalchemy as sa # revision identifiers, used by Alembic. -revision = 'f5706aa6b94c' +revision = 'cae2e31293e8' down_revision = 'ee45b9ea4a0c' branch_labels = None depends_on = None @@ -36,11 +36,17 @@ def upgrade(): with op.batch_alter_table('post_reply', schema=None) as batch_op: batch_op.add_column(sa.Column('notify_author', sa.Boolean(), nullable=True)) + with op.batch_alter_table('user', schema=None) as batch_op: + batch_op.add_column(sa.Column('unread_notifications', sa.Integer(), nullable=True)) + # ### end Alembic commands ### def downgrade(): # ### commands auto generated by Alembic - please adjust! ### + with op.batch_alter_table('user', schema=None) as batch_op: + batch_op.drop_column('unread_notifications') + with op.batch_alter_table('post_reply', schema=None) as batch_op: batch_op.drop_column('notify_author')