Merge pull request 'Cross Posts' (#144) from freamon/pyfedi:xp3 into main

Reviewed-on: https://codeberg.org/rimu/pyfedi/pulls/144
This commit is contained in:
rimu 2024-04-02 18:32:46 +00:00
commit bf03020089
12 changed files with 211 additions and 5 deletions

View file

@ -629,6 +629,13 @@ def process_inbox_request(request_json, activitypublog_id, ip_address):
activity_log.result = 'failure'
activity_log.exception_message = 'dict instead of string ' + str(to_be_deleted_ap_id)
else:
post = Post.query.filter_by(ap_id=to_be_deleted_ap_id).first()
if post and post.url and post.cross_posts is not None:
old_cross_posts = Post.query.filter(Post.id.in_(post.cross_posts)).all()
post.cross_posts.clear()
for ocp in old_cross_posts:
if ocp.cross_posts is not None:
ocp.cross_posts.remove(post.id)
delete_post_or_comment(user_ap_id, community_ap_id, to_be_deleted_ap_id)
activity_log.result = 'success'
elif request_json['object']['type'] == 'Page': # Editing a post
@ -858,6 +865,12 @@ def process_inbox_request(request_json, activitypublog_id, ip_address):
post = Post.query.filter_by(ap_id=ap_id).first()
# Delete post
if post:
if post.url and post.cross_posts is not None:
old_cross_posts = Post.query.filter(Post.id.in_(post.cross_posts)).all()
post.cross_posts.clear()
for ocp in old_cross_posts:
if ocp.cross_posts is not None:
ocp.cross_posts.remove(post.id)
post.delete_dependencies()
post.community.post_count -= 1
db.session.delete(post)

View file

@ -1397,6 +1397,20 @@ def create_post(activity_log: ActivityPubLog, community: Community, request_json
if post.image_id:
make_image_sizes(post.image_id, 150, 512, 'posts') # the 512 sized image is for masonry view
if post.url:
other_posts = Post.query.filter(Post.id != post.id, Post.url == post.url,
Post.posted_at > post.posted_at - timedelta(days=6)).all()
for op in other_posts:
if op.cross_posts is None:
op.cross_posts = [post.id]
else:
op.cross_posts.append(post.id)
if post.cross_posts is None:
post.cross_posts = [op.id]
else:
post.cross_posts.append(op.id)
db.session.commit()
notify_about_post(post)
if user.reputation > 100:
@ -1503,6 +1517,27 @@ def update_post_from_activity(post: Post, request_json: dict):
post.domain = domain
else:
post.url = old_url # don't change if url changed from non-banned domain to banned domain
new_cross_posts = Post.query.filter(Post.id != post.id, Post.url == post.url,
Post.posted_at > utcnow() - timedelta(days=6)).all()
for ncp in new_cross_posts:
if ncp.cross_posts is None:
ncp.cross_posts = [post.id]
else:
ncp.cross_posts.append(post.id)
if post.cross_posts is None:
post.cross_posts = [ncp.id]
else:
post.cross_posts.append(ncp.id)
if post.url != old_url:
if post.cross_posts is not None:
old_cross_posts = Post.query.filter(Post.id.in_(post.cross_posts)).all()
post.cross_posts.clear()
for ocp in old_cross_posts:
if ocp.cross_posts is not None:
ocp.cross_posts.remove(post.id)
if post is not None:
if 'image' in request_json['object'] and post.image is None:
image = File(source_url=request_json['object']['image']['url'])

View file

@ -470,6 +470,20 @@ def add_post(actor):
if post.image_id and post.image.file_path is None:
make_image_sizes(post.image_id, 150, 512, 'posts') # the 512 sized image is for masonry view
if post.url:
other_posts = Post.query.filter(Post.id != post.id, Post.url == post.url,
Post.posted_at > post.posted_at - timedelta(days=6)).all()
for op in other_posts:
if op.cross_posts is None:
op.cross_posts = [post.id]
else:
op.cross_posts.append(post.id)
if post.cross_posts is None:
post.cross_posts = [op.id]
else:
post.cross_posts.append(op.id)
db.session.commit()
notify_about_post(post)
if not community.local_only:

View file

@ -106,10 +106,23 @@ def retrieve_mods_and_backfill(community_id: int):
db.session.add(activity_log)
if user:
post = post_json_to_model(activity_log, activity['object']['object'], user, community)
post.ap_create_id = activity['object']['id']
post.ap_announce_id = activity['id']
post.ranking = post_ranking(post.score, post.posted_at)
db.session.commit()
if post:
post.ap_create_id = activity['object']['id']
post.ap_announce_id = activity['id']
post.ranking = post_ranking(post.score, post.posted_at)
if post.url:
other_posts = Post.query.filter(Post.id != post.id, Post.url == post.url,
Post.posted_at > post.posted_at - timedelta(days=3), Post.posted_at < post.posted_at + timedelta(days=3)).all()
for op in other_posts:
if op.cross_posts is None:
op.cross_posts = [post.id]
else:
op.cross_posts.append(post.id)
if post.cross_posts is None:
post.cross_posts = [op.id]
else:
post.cross_posts.append(op.id)
db.session.commit()
else:
activity_log.exception_message = 'Could not find or create actor'
db.session.commit()

View file

@ -10,6 +10,8 @@ from werkzeug.security import generate_password_hash, check_password_hash
from flask_babel import _, lazy_gettext as _l
from sqlalchemy.orm import backref
from sqlalchemy_utils.types import TSVectorType # https://sqlalchemy-searchable.readthedocs.io/en/latest/installation.html
from sqlalchemy.dialects.postgresql import ARRAY
from sqlalchemy.ext.mutable import MutableList
from flask_sqlalchemy import BaseQuery
from sqlalchemy_searchable import SearchQueryMixin
from app import db, login, cache, celery
@ -869,6 +871,7 @@ class Post(db.Model):
language = db.Column(db.String(10))
edited_at = db.Column(db.DateTime)
reports = db.Column(db.Integer, default=0) # how many times this post has been reported. Set to -1 to ignore reports
cross_posts = db.Column(MutableList.as_mutable(ARRAY(db.Integer)))
ap_id = db.Column(db.String(255), index=True)
ap_create_id = db.Column(db.String(100))

View file

@ -1,5 +1,5 @@
from collections import namedtuple
from datetime import datetime
from datetime import datetime, timedelta
from random import randint
from flask import redirect, url_for, flash, current_app, abort, request, g, make_response
@ -683,12 +683,36 @@ def post_edit(post_id: int):
form.nsfw.render_kw = {'disabled': True}
#form.communities.choices = [(c.id, c.display_name()) for c in current_user.communities()]
old_url = post.url
if form.validate_on_submit():
save_post(form, post)
post.community.last_active = utcnow()
post.edited_at = utcnow()
db.session.commit()
if post.url != old_url:
if post.cross_posts is not None:
old_cross_posts = Post.query.filter(Post.id.in_(post.cross_posts)).all()
post.cross_posts.clear()
for ocp in old_cross_posts:
if ocp.cross_posts is not None:
ocp.cross_posts.remove(post.id)
new_cross_posts = Post.query.filter(Post.id != post.id, Post.url == post.url,
Post.posted_at > post.edited_at - timedelta(days=6)).all()
for ncp in new_cross_posts:
if ncp.cross_posts is None:
ncp.cross_posts = [post.id]
else:
ncp.cross_posts.append(post.id)
if post.cross_posts is None:
post.cross_posts = [ncp.id]
else:
post.cross_posts.append(ncp.id)
db.session.commit()
post.flush_cache()
flash(_('Your changes have been saved.'), 'success')
# federate edit
@ -807,6 +831,13 @@ def post_delete(post_id: int):
post = Post.query.get_or_404(post_id)
community = post.community
if post.user_id == current_user.id or community.is_moderator() or current_user.is_admin():
if post.url:
if post.cross_posts is not None:
old_cross_posts = Post.query.filter(Post.id.in_(post.cross_posts)).all()
post.cross_posts.clear()
for ocp in old_cross_posts:
if ocp.cross_posts is not None:
ocp.cross_posts.remove(post.id)
post.delete_dependencies()
post.flush_cache()
db.session.delete(post)
@ -1241,3 +1272,10 @@ def post_reply_notification(post_reply_id: int):
post_reply.notify_author = not post_reply.notify_author
db.session.commit()
return render_template('post/_reply_notification_toggle.html', comment={'comment': post_reply})
@bp.route('/post/<int:post_id>/cross_posts', methods=['GET'])
def post_cross_posts(post_id: int):
post = Post.query.get_or_404(post_id)
cross_posts = Post.query.filter(Post.id.in_(post.cross_posts)).all()
return render_template('post/post_cross_posts.html', post=post, cross_posts=cross_posts)

View file

@ -174,6 +174,15 @@
content: "\e990";
}
.fe-layers {
position: relative;
top: 1px;
}
.fe-layers::before {
content: "\e97f";
}
.fe-report::before {
content: "\e967";
}

View file

@ -201,6 +201,15 @@ nav, etc which are used site-wide */
content: "\e990";
}
.fe-layers {
position: relative;
top: 1px;
}
.fe-layers::before {
content: "\e97f";
}
.fe-report::before {
content: "\e967";
}

View file

@ -200,6 +200,15 @@
content: "\e990";
}
.fe-layers {
position: relative;
top: 1px;
}
.fe-layers::before {
content: "\e97f";
}
.fe-report::before {
content: "\e967";
}

View file

@ -20,6 +20,10 @@
{% if post.nsfw %}<span class="warning_badge nsfw" title="{{ _('Not safe for work') }}">nsfw</span>{% endif %}
{% if post.nsfl %}<span class="warning_badge nsfl" title="{{ _('Potentially emotionally scarring content') }}">nsfl</span>{% endif %}
</h1>
{% if post.cross_posts %}
<a href="{{ url_for('post.post_cross_posts', post_id=post.id) }}" aria-label="{{ _('Show cross-posts') }}"><span class="fe fe-layers"></span></a>
<span aria-label="{{ _('Number of cross-posts:') }}">{{ len(post.cross_posts) }}</span>
{% endif %}
{% if post.url %}
<p><a href="{{ post.url }}" rel="nofollow ugc" target="_blank" aria-label="Go to image">{{ post.url|shorten_url }}
<span class="fe fe-external"></span></a></p>
@ -66,6 +70,10 @@
{% if post.nsfw %}<span class="warning_badge nsfw" title="{{ _('Not safe for work') }}">nsfw</span>{% endif %}
{% if post.nsfl %}<span class="warning_badge nsfl" title="{{ _('Potentially emotionally scarring content') }}">nsfl</span>{% endif %}
</h1>
{% if post.cross_posts %}
<a href="{{ url_for('post.post_cross_posts', post_id=post.id) }}" aria-label="{{ _('Show cross-posts') }}"><span class="fe fe-layers"></span></a>
<span aria-label="{{ _('Number of cross-posts:') }}">{{ len(post.cross_posts) }}</span>
{% endif %}
{% if post.type == POST_TYPE_LINK and post.image_id and not (post.url and 'youtube.com' in post.url) %}
<div class="url_thumbnail">
<a href="{{ post.url }}" target="_blank" rel="nofollow ugc" class="post_link"><img src="{{ post.image.thumbnail_url() }}" alt="{{ post.image.alt_text if post.image.alt_text else '' }}"

View file

@ -0,0 +1,23 @@
{% 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="col col-login mx-auto">
<h3 class="mt-2 post_title">{{ _('Cross-posts for "%(post_title)s"', post_title=post.title) }}</h3>
<ul class="cross_post_list">
{% for cross_post in cross_posts %}
<li><a href="{{ url_for('activitypub.post_ap', post_id=cross_post.id) }}">
{{ cross_post.community.title }}@{{ cross_post.community.ap_domain }}</a>
<span class="fe fe-reply"></span>
<span>{{ cross_post.reply_count }}</span>
</li>
{% endfor %}
</ul>
</div>
</div>
{% endblock %}

View file

@ -0,0 +1,32 @@
"""cross posts
Revision ID: 08b3f718df5d
Revises: 04697ae91fac
Create Date: 2024-03-31 02:10:19.726154
"""
from alembic import op
import sqlalchemy as sa
from sqlalchemy.dialects import postgresql
# revision identifiers, used by Alembic.
revision = '08b3f718df5d'
down_revision = '04697ae91fac'
branch_labels = None
depends_on = None
def upgrade():
# ### commands auto generated by Alembic - please adjust! ###
with op.batch_alter_table('post', schema=None) as batch_op:
batch_op.add_column(sa.Column('cross_posts', postgresql.ARRAY(sa.Integer()), nullable=True))
# ### end Alembic commands ###
def downgrade():
# ### commands auto generated by Alembic - please adjust! ###
with op.batch_alter_table('post', schema=None) as batch_op:
batch_op.drop_column('cross_posts')
# ### end Alembic commands ###