send private messages

This commit is contained in:
rimu 2024-02-17 20:05:57 +13:00
parent e4e02dcc6c
commit a77de0c883
15 changed files with 431 additions and 43 deletions

View file

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

View file

@ -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,6 +374,33 @@ 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']
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:
# 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:
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

5
app/chat/__init__.py Normal file
View file

@ -0,0 +1,5 @@
from flask import Blueprint
bp = Blueprint('chat', __name__)
from app.chat import routes

14
app/chat/forms.py Normal file
View file

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

103
app/chat/routes.py Normal file
View file

@ -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/<int:sender_id>', 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)

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

@ -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 %}
<div class="row">
<div class="row">
<div class="col main_pane">
<nav aria-label="breadcrumb" id="breadcrumb_nav" title="Navigation">
<ol class="breadcrumb">
<li class="breadcrumb-item"><a href="/">{{ _('Home') }}</a></li>
<li class="breadcrumb-item active">{{ _('Chat') }}</li>
</ol>
</nav>
<h1 class="mt-2">{{ _('Chat') }}</h1>
<div class="row">
<div class="col col-md-2 d-none d-md-block sender_list">
<h3>{{ _('People') }}</h3>
<ul class="list-group list-group-flush">
{% for sender in senders %}
<li class="list-group-item">
{% if other_party %}
{% if other_party.id != sender.id %}
<a href="{{ url_for('chat.chat_home', sender_id=sender.id) }}">{% if sender.avatar_image() %}<img src="{{ sender.avatar_image() }}" class="community_icon rounded-circle" loading="lazy" alt="" />{% endif %}
{{ sender.link() }}
</a>
{% else %}
{% if sender.avatar_image() %}<img src="{{ sender.avatar_image() }}" class="community_icon rounded-circle" loading="lazy" alt="" />{% endif %}
{{ sender.link() }}
{% endif %}
{% else %}
<a href="{{ url_for('chat.chat_home', sender_id=sender.id) }}">{% if sender.avatar_image() %}<img src="{{ sender.avatar_image() }}" class="community_icon rounded-circle" loading="lazy" alt="" />{% endif %}
{{ sender.link() }}
</a>
{% endif %}
</li>
{% endfor %}
</ul>
</div>
<div class="col col-md-10">
{% if other_party %}
<h3 class="d-none d-md-inline">{{ _('Messages with %(name)s', name=other_party.display_name()) }}</h3>
<h3 class="d-md-none">{{ _('Messages with: ') }}
<select id="changeSender">{% for sender in senders %}
<option value="{{ sender.id }}" {{ 'selected' if sender.id == other_party.id }}>{{ sender.link() }}</option>
{% endfor %}
</select></h3>
<div class="conversation">
{% for message in messages %}
<div id="message_{{ message.id }}" class="card message {{ 'from_other_party' if message.sender_id != current_user.id else 'from_me' }}">
<div class="message_body">
<span class="message_created_at text-muted small">{{ moment(message.created_at).fromNow(refresh=True) }}</span>
<span class="message_sender">{{ message.sender.display_name() }}</span>: {{ message.body_html|safe }}
</div>
</div>
{% endfor %}
{{ render_form(form) }}
</div>
{% endif %}
</div>
</div>
</div>
</div>
</div>
{% endblock %}

View file

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

View file

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