From a77de0c883b651e6908bb45ef5ce14acf2984894 Mon Sep 17 00:00:00 2001 From: rimu <3310831+rimu@users.noreply.github.com> Date: Sat, 17 Feb 2024 20:05:57 +1300 Subject: [PATCH] send private messages --- app/__init__.py | 3 + app/activitypub/routes.py | 108 ++++++++++++++--------- app/chat/__init__.py | 5 ++ app/chat/forms.py | 14 +++ app/chat/routes.py | 103 +++++++++++++++++++++ app/models.py | 15 ++++ app/post/routes.py | 4 +- app/static/js/scripts.js | 11 +++ app/static/structure.css | 24 +++++ app/static/structure.scss | 24 +++++ app/static/styles.css | 18 ++++ app/static/styles.scss | 20 +++++ app/templates/chat/home.html | 68 ++++++++++++++ app/user/routes.py | 2 +- migrations/versions/fe1e3fbf5b9d_chat.py | 55 ++++++++++++ 15 files changed, 431 insertions(+), 43 deletions(-) create mode 100644 app/chat/__init__.py create mode 100644 app/chat/forms.py create mode 100644 app/chat/routes.py create mode 100644 app/templates/chat/home.html create mode 100644 migrations/versions/fe1e3fbf5b9d_chat.py diff --git a/app/__init__.py b/app/__init__.py index 68172e37..550b84fe 100644 --- a/app/__init__.py +++ b/app/__init__.py @@ -77,6 +77,9 @@ def create_app(config_class=Config): from app.topic import bp as topic_bp app.register_blueprint(topic_bp) + from app.chat import bp as chat_bp + app.register_blueprint(chat_bp) + def get_resource_as_string(name, charset='utf-8'): with app.open_resource(name) as f: return f.read().decode(charset) diff --git a/app/activitypub/routes.py b/app/activitypub/routes.py index 8ca89428..6f581881 100644 --- a/app/activitypub/routes.py +++ b/app/activitypub/routes.py @@ -11,7 +11,8 @@ from app.post.routes import continue_discussion, show_post from app.user.routes import show_profile from app.constants import POST_TYPE_LINK, POST_TYPE_IMAGE, SUBSCRIPTION_MEMBER from app.models import User, Community, CommunityJoinRequest, CommunityMember, CommunityBan, ActivityPubLog, Post, \ - PostReply, Instance, PostVote, PostReplyVote, File, AllowedInstances, BannedInstances, utcnow, Site, Notification + PostReply, Instance, PostVote, PostReplyVote, File, AllowedInstances, BannedInstances, utcnow, Site, Notification, \ + ChatMessage from app.activitypub.util import public_key, users_total, active_half_year, active_month, local_posts, local_comments, \ post_to_activity, find_actor_or_create, default_context, instance_blocked, find_reply_parent, find_liked_object, \ lemmy_site_data, instance_weight, is_activitypub_request, downvote_post_reply, downvote_post, upvote_post_reply, \ @@ -20,7 +21,7 @@ from app.activitypub.util import public_key, users_total, active_half_year, acti update_post_from_activity, undo_vote, undo_downvote from app.utils import gibberish, get_setting, is_image_url, allowlist_html, html_to_markdown, render_template, \ domain_from_url, markdown_to_html, community_membership, ap_datetime, markdown_to_text, ip_address, can_downvote, \ - can_upvote, can_create, awaken_dormant_instance + can_upvote, can_create, awaken_dormant_instance, shorten_string import werkzeug.exceptions @@ -373,46 +374,73 @@ def process_inbox_request(request_json, activitypublog_id, ip_address): if request_json['type'] == 'Create': activity_log.activity_type = 'Create' user_ap_id = request_json['object']['attributedTo'] - try: - community_ap_id = request_json['to'][0] - if community_ap_id == 'https://www.w3.org/ns/activitystreams#Public': # kbin does this when posting a reply - if 'to' in request_json['object'] and request_json['object']['to']: - community_ap_id = request_json['object']['to'][0] - if community_ap_id == 'https://www.w3.org/ns/activitystreams#Public' and 'cc' in \ - request_json['object'] and request_json['object']['cc']: - community_ap_id = request_json['object']['cc'][0] - elif 'cc' in request_json['object'] and request_json['object']['cc']: - community_ap_id = request_json['object']['cc'][0] - if community_ap_id.endswith('/followers'): # mastodon - if 'inReplyTo' in request_json['object']: - post_being_replied_to = Post.query.filter_by(ap_id=request_json['object']['inReplyTo']).first() - if post_being_replied_to: - community_ap_id = post_being_replied_to.community.ap_profile_id - except: - activity_log.activity_type = 'exception' - db.session.commit() - return - community = find_actor_or_create(community_ap_id) - user = find_actor_or_create(user_ap_id) - if (user and not user.is_local()) and community: - user.last_seen = community.last_active = site.last_active = utcnow() - - object_type = request_json['object']['type'] - new_content_types = ['Page', 'Article', 'Link', 'Note'] - if object_type in new_content_types: # create a new post - in_reply_to = request_json['object']['inReplyTo'] if 'inReplyTo' in request_json['object'] else None - if not in_reply_to: - post = create_post(activity_log, community, request_json, user) + if request_json['object']['type'] == 'ChatMessage': + activity_log.activity_type = 'Create ChatMessage' + sender = find_actor_or_create(user_ap_id) + recipient_ap_id = request_json['object']['to'][0] + recipient = find_actor_or_create(recipient_ap_id) + if sender and recipient and recipient.is_local(): + if recipient.has_blocked_user(sender.id) or recipient.has_blocked_instance(sender.instance_id): + activity_log.exception_message = "Sender blocked by recipient" else: - post = create_post_reply(activity_log, community, in_reply_to, request_json, user) - else: - activity_log.exception_message = 'Unacceptable type (create): ' + object_type + # Save ChatMessage to DB + encrypted = request_json['object']['encrypted'] if 'encrypted' in request_json['object'] else None + new_message = ChatMessage(sender_id=sender.id, recipient_id=recipient.id, + body=request_json['object']['source']['content'], + body_html=allowlist_html(markdown_to_html(request_json['object']['source']['content'])), + encrypted=encrypted) + db.session.add(new_message) + db.session.commit() + + # Notify recipient + notify = Notification(title=shorten_string('New message from ' + sender.display_name()), + url=f'/chat/{new_message.id}', user_id=recipient.id, + author_id=sender.id) + db.session.add(notify) + recipient.unread_notifications += 1 + db.session.commit() + activity_log.result = 'success' else: - if user is None or community is None: - activity_log.exception_message = 'Blocked or unfound user or community' - if user and user.is_local(): - activity_log.exception_message = 'Activity about local content which is already present' - activity_log.result = 'ignored' + try: + community_ap_id = request_json['to'][0] + if community_ap_id == 'https://www.w3.org/ns/activitystreams#Public': # kbin does this when posting a reply + if 'to' in request_json['object'] and request_json['object']['to']: + community_ap_id = request_json['object']['to'][0] + if community_ap_id == 'https://www.w3.org/ns/activitystreams#Public' and 'cc' in \ + request_json['object'] and request_json['object']['cc']: + community_ap_id = request_json['object']['cc'][0] + elif 'cc' in request_json['object'] and request_json['object']['cc']: + community_ap_id = request_json['object']['cc'][0] + if community_ap_id.endswith('/followers'): # mastodon + if 'inReplyTo' in request_json['object']: + post_being_replied_to = Post.query.filter_by(ap_id=request_json['object']['inReplyTo']).first() + if post_being_replied_to: + community_ap_id = post_being_replied_to.community.ap_profile_id + except: + activity_log.activity_type = 'exception' + db.session.commit() + return + community = find_actor_or_create(community_ap_id) + user = find_actor_or_create(user_ap_id) + if (user and not user.is_local()) and community: + user.last_seen = community.last_active = site.last_active = utcnow() + + object_type = request_json['object']['type'] + new_content_types = ['Page', 'Article', 'Link', 'Note'] + if object_type in new_content_types: # create a new post + in_reply_to = request_json['object']['inReplyTo'] if 'inReplyTo' in request_json['object'] else None + if not in_reply_to: + post = create_post(activity_log, community, request_json, user) + else: + post = create_post_reply(activity_log, community, in_reply_to, request_json, user) + else: + activity_log.exception_message = 'Unacceptable type (create): ' + object_type + else: + if user is None or community is None: + activity_log.exception_message = 'Blocked or unfound user or community' + if user and user.is_local(): + activity_log.exception_message = 'Activity about local content which is already present' + activity_log.result = 'ignored' # Announce is new content and votes that happened on a remote server. if request_json['type'] == 'Announce': diff --git a/app/chat/__init__.py b/app/chat/__init__.py new file mode 100644 index 00000000..b6ff290a --- /dev/null +++ b/app/chat/__init__.py @@ -0,0 +1,5 @@ +from flask import Blueprint + +bp = Blueprint('chat', __name__) + +from app.chat import routes diff --git a/app/chat/forms.py b/app/chat/forms.py new file mode 100644 index 00000000..fe9afd89 --- /dev/null +++ b/app/chat/forms.py @@ -0,0 +1,14 @@ +from flask import request, g +from flask_login import current_user +from flask_wtf import FlaskForm +from wtforms import StringField, SubmitField, TextAreaField, BooleanField, HiddenField, SelectField, FileField +from wtforms.validators import ValidationError, DataRequired, Email, EqualTo, Length, Optional +from flask_babel import _, lazy_gettext as _l + +from app import db + + +class AddReply(FlaskForm): + message = TextAreaField(_l('Message'), validators=[DataRequired(), Length(min=1, max=5000)], render_kw={'placeholder': 'Type a reply here...'}) + recipient_id = HiddenField() + submit = SubmitField(_l('Reply')) diff --git a/app/chat/routes.py b/app/chat/routes.py new file mode 100644 index 00000000..09b7e619 --- /dev/null +++ b/app/chat/routes.py @@ -0,0 +1,103 @@ +from datetime import datetime, timedelta + +from flask import request, flash, json, url_for, current_app, redirect, g +from flask_login import login_required, current_user +from flask_babel import _ +from sqlalchemy import desc, or_, and_, text + +from app import db, celery +from app.activitypub.signature import post_request +from app.chat.forms import AddReply +from app.models import AllowedInstances, BannedInstances, ActivityPubLog, utcnow, Site, Community, CommunityMember, \ + User, Instance, File, Report, Topic, UserRegistration, ChatMessage, Notification +from app.utils import render_template, permission_required, set_setting, get_setting, gibberish, markdown_to_html, \ + moderating_communities, joined_communities, finalize_user_setup, theme_list, allowlist_html, shorten_string +from app.chat import bp + + +@bp.route('/chat', methods=['GET', 'POST']) +@bp.route('/chat/', methods=['GET', 'POST']) +@login_required +def chat_home(sender_id=None): + form = AddReply() + if form.validate_on_submit(): + recipient = User.query.get(form.recipient_id.data) + reply = ChatMessage(sender_id=current_user.id, recipient_id=recipient.id, + body=form.message.data, body_html=allowlist_html(markdown_to_html(form.message.data))) + if recipient.is_local(): + # Notify local recipient + notify = Notification(title=shorten_string('New message from ' + current_user.display_name()), url='/chat/' + str(current_user.id), + user_id=recipient.id, + author_id=current_user.id) + db.session.add(notify) + recipient.unread_notifications += 1 + db.session.add(reply) + db.session.commit() + else: + db.session.add(reply) + db.session.commit() + # Federate reply + reply_json = { + "actor": current_user.profile_id(), + "id": f"https://{current_app.config['SERVER_NAME']}/activities/create/{gibberish(15)}", + "object": { + "attributedTo": current_user.profile_id(), + "content": reply.body_html, + "id": f"https://{current_app.config['SERVER_NAME']}/private_message/{reply.id}", + "mediaType": "text/html", + "published": utcnow().isoformat() + 'Z', # Lemmy is inconsistent with the date format they use + "source": { + "content": reply.body, + "mediaType": "text/markdown" + }, + "to": [ + recipient.profile_id() + ], + "type": "ChatMessage" + }, + "to": [ + recipient.profile_id() + ], + "type": "Create" + } + success = post_request(recipient.ap_inbox_url, reply_json, current_user.private_key, + current_user.profile_id() + '#main-key') + if not success: + flash(_('Message failed to send to remote server. Try again later.'), 'error') + + return redirect(url_for('chat.chat_home', sender_id=recipient.id, _anchor=f'message_{reply.id}')) + else: + senders = User.query.filter(User.banned == False).join(ChatMessage, ChatMessage.sender_id == User.id) + senders = senders.filter(ChatMessage.recipient_id == current_user.id).order_by(desc(ChatMessage.created_at)).limit(500).all() + + if senders: + messages_with = senders[0].id if sender_id is None else sender_id + sender_id = messages_with + messages = ChatMessage.query.filter(or_( + and_(ChatMessage.recipient_id == current_user.id, ChatMessage.sender_id == messages_with), + and_(ChatMessage.recipient_id == messages_with, ChatMessage.sender_id == current_user.id)) + ) + messages = messages.order_by(ChatMessage.created_at).all() + if messages: + if messages[0].sender_id == current_user.id: + other_party = User.query.get(messages[0].recipient_id) + else: + other_party = User.query.get(messages[0].sender_id) + else: + other_party = None + form.recipient_id.data = messages_with + else: + messages = [] + other_party = None + if sender_id and int(sender_id): + sql = f"UPDATE notification SET read = true WHERE url = '/chat/{sender_id}' AND user_id = {current_user.id}" + db.session.execute(text(sql)) + db.session.commit() + current_user.unread_notifications = Notification.query.filter_by(user_id=current_user.id, read=False).count() + db.session.commit() + + return render_template('chat/home.html', title=_('Chat'), senders=senders, messages=messages, other_party=other_party, + form=form, + moderating_communities=moderating_communities(current_user.get_id()), + joined_communities=joined_communities(current_user.get_id()), + site=g.site) \ No newline at end of file diff --git a/app/models.py b/app/models.py index 92cca8ad..e226bb4e 100644 --- a/app/models.py +++ b/app/models.py @@ -959,6 +959,20 @@ class PostReplyVote(db.Model): created_at = db.Column(db.DateTime, default=utcnow) +class ChatMessage(db.Model): + id = db.Column(db.Integer, primary_key=True) + sender_id = db.Column(db.Integer, db.ForeignKey('user.id'), index=True) + recipient_id = db.Column(db.Integer, db.ForeignKey('user.id'), index=True) + body = db.Column(db.Text) + body_html = db.Column(db.Text) + reported = db.Column(db.Boolean, default=False) + read = db.Column(db.Boolean, default=False) + encrypted = db.Column(db.String(15)) + created_at = db.Column(db.DateTime, default=utcnow) + + sender = db.relationship('User', foreign_keys=[sender_id]) + + # save every activity to a log, to aid debugging class ActivityPubLog(db.Model): id = db.Column(db.Integer, primary_key=True) @@ -1064,6 +1078,7 @@ class Site(db.Model): allow_or_block_list = db.Column(db.Integer, default=2) # 1 = allow list, 2 = block list allowlist = db.Column(db.Text, default='') blocklist = db.Column(db.Text, default='') + auto_decline_referrers = db.Column(db.Text, default='rdrama.net') created_at = db.Column(db.DateTime, default=utcnow) updated = db.Column(db.DateTime, default=utcnow) last_active = db.Column(db.DateTime, default=utcnow) diff --git a/app/post/routes.py b/app/post/routes.py index dffbb163..d5182c62 100644 --- a/app/post/routes.py +++ b/app/post/routes.py @@ -759,7 +759,7 @@ def post_report(post_id: int): # todo: only notify admins for certain types of report for admin in Site.admins(): if admin.id not in already_notified: - notify = Notification(title='Suspicious content', url=post.ap_id, user_id=admin.id, author_id=current_user.id) + notify = Notification(title='Suspicious content', url='/admin/reports', user_id=admin.id, author_id=current_user.id) db.session.add(notify) admin.unread_notifications += 1 db.session.commit() @@ -861,7 +861,7 @@ def post_reply_report(post_id: int, comment_id: int): # todo: only notify admins for certain types of report for admin in Site.admins(): if admin.id not in already_notified: - notify = Notification(title='Suspicious content', url=post.ap_id, user_id=admin.id, author_id=current_user.id) + notify = Notification(title='Suspicious content', url='/admin/reports', user_id=admin.id, author_id=current_user.id) db.session.add(notify) admin.unread_notifications += 1 db.session.commit() diff --git a/app/static/js/scripts.js b/app/static/js/scripts.js index 32d3c7ba..a8c2f365 100644 --- a/app/static/js/scripts.js +++ b/app/static/js/scripts.js @@ -10,6 +10,7 @@ document.addEventListener("DOMContentLoaded", function () { setupLightDark(); setupKeyboardShortcuts(); setupTopicChooser(); + setupConversationChooser(); }); @@ -508,6 +509,16 @@ function setupTopicChooser() { }); } +function setupConversationChooser() { + const changeSender = document.getElementById('changeSender'); + if(changeSender) { + changeSender.addEventListener('change', function() { + const user_id = changeSender.options[changeSender.selectedIndex].value; + location.href = '/chat/' + user_id; + }); + } +} + function formatTime(seconds) { const hours = Math.floor(seconds / 3600); const minutes = Math.floor((seconds % 3600) / 60); diff --git a/app/static/structure.css b/app/static/structure.css index bff9c1fd..8cf7a504 100644 --- a/app/static/structure.css +++ b/app/static/structure.css @@ -1205,4 +1205,28 @@ fieldset legend { line-height: 32px; } +.conversation .message { + width: 90%; + max-width: 100%; + margin-bottom: 15px; + clear: both; +} +@media (min-width: 992px) { + .conversation .message { + width: 70%; + } +} +.conversation .message.from_other_party { + float: right; +} +.conversation .message.from_me { + float: left; +} +.conversation .message_created_at { + float: right; +} +.conversation form .form-control-label { + display: none; +} + /*# sourceMappingURL=structure.css.map */ diff --git a/app/static/structure.scss b/app/static/structure.scss index feb3bc51..2e677ab3 100644 --- a/app/static/structure.scss +++ b/app/static/structure.scss @@ -895,3 +895,27 @@ fieldset { } } +.conversation { + .message { + width: 90%; + @include breakpoint(tablet) { + width: 70%; + } + max-width: 100%; + margin-bottom: 15px; + clear: both; + + &.from_other_party { + float: right; + } + &.from_me { + float: left; + } + } + .message_created_at { + float: right; + } + form .form-control-label { + display: none; + } +} \ No newline at end of file diff --git a/app/static/styles.css b/app/static/styles.css index dacf2833..5af33c2f 100644 --- a/app/static/styles.css +++ b/app/static/styles.css @@ -693,6 +693,24 @@ div.navbar { } } +.sender_list { + border-right: solid 1px #ddd; +} + +.message { + border: solid 1px #ddd; + border-radius: 5px; + padding: 8px 15px 0 15px; +} +.message.from_other_party { + float: right; +} +.message.from_me { + color: var(--bs-card-cap-color); + background-color: var(--bs-card-cap-bg); +} + +/* high contrast */ @media (prefers-contrast: more) { :root { --bs-link-color: black; diff --git a/app/static/styles.scss b/app/static/styles.scss index 5b1e0c03..ac95255b 100644 --- a/app/static/styles.scss +++ b/app/static/styles.scss @@ -319,6 +319,26 @@ div.navbar { } } +.sender_list { + border-right: solid 1px #ddd; +} + +.message { + border: solid 1px #ddd; + border-radius: 5px; + padding: 8px 15px 0 15px; + + &.from_other_party { + float: right; + } + &.from_me { + color: var(--bs-card-cap-color); + background-color: var(--bs-card-cap-bg); + } +} + + +/* high contrast */ @media (prefers-contrast: more) { :root { --bs-link-color: black; diff --git a/app/templates/chat/home.html b/app/templates/chat/home.html new file mode 100644 index 00000000..d4460ef6 --- /dev/null +++ b/app/templates/chat/home.html @@ -0,0 +1,68 @@ +{% if theme() and file_exists('app/templates/themes/' + theme() + '/base.html') %} + {% extends 'themes/' + theme() + '/base.html' %} +{% else %} + {% extends "base.html" %} +{% endif %} %} +{% from 'bootstrap/form.html' import render_form %} + +{% block app_content %} +
+
+
+ +

{{ _('Chat') }}

+
+
+

{{ _('People') }}

+ +
+
+ {% if other_party %} +

{{ _('Messages with %(name)s', name=other_party.display_name()) }}

+

{{ _('Messages with: ') }} +

+
+ {% for message in messages %} +
+
+ {{ moment(message.created_at).fromNow(refresh=True) }} + {{ message.sender.display_name() }}: {{ message.body_html|safe }} +
+
+ {% endfor %} + {{ render_form(form) }} +
+ {% endif %} +
+
+
+
+
+{% endblock %} \ No newline at end of file diff --git a/app/user/routes.py b/app/user/routes.py index a01da016..7f1ec83f 100644 --- a/app/user/routes.py +++ b/app/user/routes.py @@ -325,7 +325,7 @@ def report_profile(actor): already_notified = set() for admin in Site.admins(): if admin.id not in already_notified: - notify = Notification(title='Reported user', url=user.ap_id, user_id=admin.id, author_id=current_user.id) + notify = Notification(title='Reported user', url='/admin/reports', user_id=admin.id, author_id=current_user.id) db.session.add(notify) admin.unread_notifications += 1 user.reports += 1 diff --git a/migrations/versions/fe1e3fbf5b9d_chat.py b/migrations/versions/fe1e3fbf5b9d_chat.py new file mode 100644 index 00000000..f379f849 --- /dev/null +++ b/migrations/versions/fe1e3fbf5b9d_chat.py @@ -0,0 +1,55 @@ +"""chat + +Revision ID: fe1e3fbf5b9d +Revises: 75f5b458c2f9 +Create Date: 2024-02-17 09:53:47.915062 + +""" +from alembic import op +import sqlalchemy as sa + + +# revision identifiers, used by Alembic. +revision = 'fe1e3fbf5b9d' +down_revision = '75f5b458c2f9' +branch_labels = None +depends_on = None + + +def upgrade(): + # ### commands auto generated by Alembic - please adjust! ### + op.create_table('chat_message', + sa.Column('id', sa.Integer(), nullable=False), + sa.Column('sender_id', sa.Integer(), nullable=True), + sa.Column('recipient_id', sa.Integer(), nullable=True), + sa.Column('body', sa.Text(), nullable=True), + sa.Column('body_html', sa.Text(), nullable=True), + sa.Column('reported', sa.Boolean(), nullable=True), + sa.Column('read', sa.Boolean(), nullable=True), + sa.Column('encrypted', sa.String(length=15), nullable=True), + sa.Column('created_at', sa.DateTime(), nullable=True), + sa.ForeignKeyConstraint(['recipient_id'], ['user.id'], ), + sa.ForeignKeyConstraint(['sender_id'], ['user.id'], ), + sa.PrimaryKeyConstraint('id') + ) + with op.batch_alter_table('chat_message', schema=None) as batch_op: + batch_op.create_index(batch_op.f('ix_chat_message_recipient_id'), ['recipient_id'], unique=False) + batch_op.create_index(batch_op.f('ix_chat_message_sender_id'), ['sender_id'], unique=False) + + with op.batch_alter_table('site', schema=None) as batch_op: + batch_op.add_column(sa.Column('auto_decline_referrers', sa.Text(), nullable=True)) + + # ### end Alembic commands ### + + +def downgrade(): + # ### commands auto generated by Alembic - please adjust! ### + with op.batch_alter_table('site', schema=None) as batch_op: + batch_op.drop_column('auto_decline_referrers') + + with op.batch_alter_table('chat_message', schema=None) as batch_op: + batch_op.drop_index(batch_op.f('ix_chat_message_sender_id')) + batch_op.drop_index(batch_op.f('ix_chat_message_recipient_id')) + + op.drop_table('chat_message') + # ### end Alembic commands ###