From 786d6190f856fddb32157764717f871c6f8cb3fa Mon Sep 17 00:00:00 2001 From: Andrew Godwin Date: Thu, 24 Nov 2022 17:11:04 -0700 Subject: [PATCH] Delete mechanics and refactor of post fanout --- activities/models/fan_out.py | 30 ++++++++- activities/models/post.py | 104 ++++++++++++++++++-------------- static/css/style.css | 33 ++++++++++ stator/runner.py | 2 +- templates/activities/_post.html | 8 +++ 5 files changed, 129 insertions(+), 48 deletions(-) diff --git a/activities/models/fan_out.py b/activities/models/fan_out.py index 285ecc2..5eb20f3 100644 --- a/activities/models/fan_out.py +++ b/activities/models/fan_out.py @@ -20,21 +20,43 @@ class FanOutStates(StateGraph): fan_out = await instance.afetch_full() # Handle Posts if fan_out.type == FanOut.Types.post: + post = await fan_out.subject_post.afetch_full() if fan_out.identity.local: # Make a timeline event directly + # TODO: Exclude replies to people we don't follow await sync_to_async(TimelineEvent.add_post)( identity=fan_out.identity, - post=fan_out.subject_post, + post=post, ) + # We might have been mentioned + if fan_out.identity in list(post.mentions.all()): + TimelineEvent.add_mentioned( + identity=fan_out.identity, + post=post, + ) else: - # Send it to the remote inbox - post = await fan_out.subject_post.afetch_full() # Sign it and send it await post.author.signed_request( method="post", uri=fan_out.identity.inbox_uri, body=canonicalise(post.to_create_ap()), ) + # Handle deleting posts + elif fan_out.type == FanOut.Types.post_deleted: + post = await fan_out.subject_post.afetch_full() + if fan_out.identity.local: + # Remove all timeline events mentioning it + await TimelineEvent.objects.filter( + identity=fan_out.identity, + subject_post=post, + ).adelete() + else: + # Send it to the remote inbox + await post.author.signed_request( + method="post", + uri=fan_out.identity.inbox_uri, + body=canonicalise(post.to_delete_ap()), + ) # Handle boosts/likes elif fan_out.type == FanOut.Types.interaction: interaction = await fan_out.subject_post_interaction.afetch_full() @@ -79,6 +101,8 @@ class FanOut(StatorModel): class Types(models.TextChoices): post = "post" + post_edited = "post_edited" + post_deleted = "post_deleted" interaction = "interaction" undo_interaction = "undo_interaction" diff --git a/activities/models/post.py b/activities/models/post.py index c86ec6a..c8165d6 100644 --- a/activities/models/post.py +++ b/activities/models/post.py @@ -1,5 +1,5 @@ import re -from typing import Dict, Optional +from typing import Dict, Iterable, Optional import httpx import urlman @@ -10,19 +10,21 @@ from django.utils import timezone from django.utils.safestring import mark_safe from activities.models.fan_out import FanOut -from activities.models.timeline_event import TimelineEvent from core.html import sanitize_post, strip_html from core.ld import canonicalise, format_ld_date, get_list, parse_ld_date from stator.models import State, StateField, StateGraph, StatorModel -from users.models.follow import Follow from users.models.identity import Identity class PostStates(StateGraph): new = State(try_interval=300) - fanned_out = State() + fanned_out = State(externally_progressed=True) + deleted = State(try_interval=300) + deleted_fanned_out = State() new.transitions_to(fanned_out) + fanned_out.transitions_to(deleted) + deleted.transitions_to(deleted_fanned_out) @classmethod async def handle_new(cls, instance: "Post"): @@ -30,39 +32,29 @@ class PostStates(StateGraph): Creates all needed fan-out objects for a new Post. """ post = await instance.afetch_full() - # Non-local posts should not be here - # TODO: This seems to keep happening. Work out how? - if not post.local: - print(f"Trying to run handle_new on a non-local post {post.pk}!") - return cls.fanned_out - # Build list of targets - mentions always included - targets = set() - async for mention in post.mentions.all(): - targets.add(mention) - # Then, if it's not mentions only, also deliver to followers - if post.visibility != Post.Visibilities.mentioned: - async for follower in post.author.inbound_follows.select_related("source"): - targets.add(follower.source) - # If it's a reply, always include the original author if we know them - reply_post = await post.ain_reply_to_post() - if reply_post: - targets.add(reply_post.author) - # Fan out to each one - for follow in targets: + # Fan out to each target + for follow in await post.aget_targets(): await FanOut.objects.acreate( identity=follow, type=FanOut.Types.post, subject_post=post, ) - # And one for themselves if they're local - # (most views will do this at time of post, but it's idempotent) - if post.author.local: + return cls.fanned_out + + @classmethod + async def handle_deleted(cls, instance: "Post"): + """ + Creates all needed fan-out objects needed to delete a Post. + """ + post = await instance.afetch_full() + # Fan out to each target + for follow in await post.aget_targets(): await FanOut.objects.acreate( - identity_id=post.author_id, - type=FanOut.Types.post, + identity=follow, + type=FanOut.Types.post_deleted, subject_post=post, ) - return cls.fanned_out + return cls.deleted_fanned_out class Post(StatorModel): @@ -339,6 +331,43 @@ class Post(StatorModel): "object": object, } + def to_delete_ap(self): + """ + Returns the AP JSON to create this object + """ + object = self.to_ap() + return { + "to": object["to"], + "cc": object.get("cc", []), + "type": "Delete", + "id": self.object_uri + "#delete", + "actor": self.author.actor_uri, + "object": object, + } + + async def aget_targets(self) -> Iterable[Identity]: + """ + Returns a list of Identities that need to see posts and their changes + """ + targets = set() + async for mention in self.mentions.all(): + targets.add(mention) + # Then, if it's not mentions only, also deliver to followers + if self.visibility != Post.Visibilities.mentioned: + async for follower in self.author.inbound_follows.select_related("source"): + targets.add(follower.source) + # If it's a reply, always include the original author if we know them + reply_post = await self.ain_reply_to_post() + if reply_post: + targets.add(reply_post.author) + # If this is a remote post, filter to only include local identities + if not self.local: + targets = {target for target in targets if target.local} + # If it's a local post, include the author + else: + targets.add(self.author) + return targets + ### ActivityPub (inbound) ### @classmethod @@ -451,21 +480,8 @@ class Post(StatorModel): # Ensure the Create actor is the Post's attributedTo if data["actor"] != data["object"]["attributedTo"]: raise ValueError("Create actor does not match its Post object", data) - # Create it - post = cls.by_ap(data["object"], create=True, update=True) - # Make timeline events for followers if it's not a reply - # TODO: _do_ show replies to people we follow somehow - if not post.in_reply_to: - for follow in Follow.objects.filter( - target=post.author, source__local=True - ): - TimelineEvent.add_post(follow.source, post) - # Make timeline events for mentions if they're local - for mention in post.mentions.all(): - if mention.local: - TimelineEvent.add_mentioned(mention, post) - # Force it into fanned_out as it's not ours - post.transition_perform(PostStates.fanned_out) + # Create it, stator will fan it out locally + cls.by_ap(data["object"], create=True, update=True) @classmethod def handle_update_ap(cls, data): diff --git a/static/css/style.css b/static/css/style.css index 642057f..8660ae2 100644 --- a/static/css/style.css +++ b/static/css/style.css @@ -775,16 +775,23 @@ h1.identity small { } .post .actions { + position: relative; float: right; padding: 3px 5px 0 0; } .post .actions a { + text-align: center; cursor: pointer; color: var(--color-text-dull); margin-right: 5px; } +.post .actions a.menu { + width: 16px; + display: inline-block; +} + .post .actions a:hover { color: var(--color-text-main); } @@ -793,6 +800,32 @@ h1.identity small { color: var(--color-highlight); } +.post .actions menu { + position: absolute; + display: none; + top: 25px; + right: 10px; + background-color: var(--color-bg-menu); + border-radius: 5px; + padding: 5px 10px; + box-shadow: 0 0 10px rgba(0, 0, 0, 0.5); +} + +.post .actions menu.enabled { + display: block; +} + +.post .actions menu a { + text-align: left; + display: block; + width: 160px; + font-size: 15px; +} + +.post .actions menu a i { + margin-right: 4px; +} + .boost-banner, .mention-banner, .follow-banner, diff --git a/stator/runner.py b/stator/runner.py index a954a2e..cb97f6e 100644 --- a/stator/runner.py +++ b/stator/runner.py @@ -15,7 +15,7 @@ from stator.models import StatorModel class StatorRunner: """ Runs tasks on models that are looking for state changes. - Designed to run for a determinate amount of time, and then exit. + Designed to run either indefinitely, or just for a few seconds. """ def __init__( diff --git a/templates/activities/_post.html b/templates/activities/_post.html index dbdc99a..e109e9c 100644 --- a/templates/activities/_post.html +++ b/templates/activities/_post.html @@ -30,6 +30,14 @@ {% include "activities/_reply.html" %} {% include "activities/_like.html" %} {% include "activities/_boost.html" %} + + + + + + Delete + + {% endif %}