diff --git a/activities/models/fan_out.py b/activities/models/fan_out.py index a86e30a..14f52a4 100644 --- a/activities/models/fan_out.py +++ b/activities/models/fan_out.py @@ -17,14 +17,12 @@ class FanOutStates(StateGraph): """ Sends the fan-out to the right inbox. """ - LOCAL_IDENTITY = True - REMOTE_IDENTITY = False fan_out = await instance.afetch_full() match (fan_out.type, fan_out.identity.local): # Handle creating/updating local posts - case (FanOut.Types.post | FanOut.Types.post_edited, LOCAL_IDENTITY): + case ((FanOut.Types.post | FanOut.Types.post_edited), True): post = await fan_out.subject_post.afetch_full() # Make a timeline event directly # If it's a reply, we only add it if we follow at least one @@ -50,7 +48,7 @@ class FanOutStates(StateGraph): ) # Handle sending remote posts create - case (FanOut.Types.post, REMOTE_IDENTITY): + case (FanOut.Types.post, False): post = await fan_out.subject_post.afetch_full() # Sign it and send it await post.author.signed_request( @@ -60,7 +58,7 @@ class FanOutStates(StateGraph): ) # Handle sending remote posts update - case (FanOut.Types.post_edited, REMOTE_IDENTITY): + case (FanOut.Types.post_edited, False): post = await fan_out.subject_post.afetch_full() # Sign it and send it await post.author.signed_request( @@ -70,7 +68,7 @@ class FanOutStates(StateGraph): ) # Handle deleting local posts - case (FanOut.Types.post_deleted, LOCAL_IDENTITY): + case (FanOut.Types.post_deleted, True): post = await fan_out.subject_post.afetch_full() if fan_out.identity.local: # Remove all timeline events mentioning it @@ -80,7 +78,7 @@ class FanOutStates(StateGraph): ).adelete() # Handle sending remote post deletes - case (FanOut.Types.post_deleted, REMOTE_IDENTITY): + case (FanOut.Types.post_deleted, False): post = await fan_out.subject_post.afetch_full() # Send it to the remote inbox await post.author.signed_request( @@ -90,7 +88,7 @@ class FanOutStates(StateGraph): ) # Handle local boosts/likes - case (FanOut.Types.interaction, LOCAL_IDENTITY): + case (FanOut.Types.interaction, True): interaction = await fan_out.subject_post_interaction.afetch_full() # Make a timeline event directly await sync_to_async(TimelineEvent.add_post_interaction)( @@ -99,7 +97,7 @@ class FanOutStates(StateGraph): ) # Handle sending remote boosts/likes - case (FanOut.Types.interaction, REMOTE_IDENTITY): + case (FanOut.Types.interaction, False): interaction = await fan_out.subject_post_interaction.afetch_full() # Send it to the remote inbox await interaction.identity.signed_request( @@ -109,7 +107,7 @@ class FanOutStates(StateGraph): ) # Handle undoing local boosts/likes - case (FanOut.Types.undo_interaction, LOCAL_IDENTITY): # noqa:F841 + case (FanOut.Types.undo_interaction, True): # noqa:F841 interaction = await fan_out.subject_post_interaction.afetch_full() # Delete any local timeline events @@ -119,7 +117,7 @@ class FanOutStates(StateGraph): ) # Handle sending remote undoing boosts/likes - case (FanOut.Types.undo_interaction, REMOTE_IDENTITY): # noqa:F841 + case (FanOut.Types.undo_interaction, False): # noqa:F841 interaction = await fan_out.subject_post_interaction.afetch_full() # Send an undo to the remote inbox await interaction.identity.signed_request( diff --git a/activities/models/post.py b/activities/models/post.py index 23194b3..f504fcb 100644 --- a/activities/models/post.py +++ b/activities/models/post.py @@ -1,5 +1,5 @@ import re -from typing import Dict, Iterable, Optional +from typing import Dict, Iterable, Optional, Set import httpx import urlman @@ -244,6 +244,12 @@ class Post(StatorModel): """ return self.linkify_mentions(sanitize_post(self.content)) + def safe_content_plain(self): + """ + Returns the content formatted as plain text + """ + return self.linkify_mentions(sanitize_post(self.content)) + ### Async helpers ### async def afetch_full(self): @@ -256,7 +262,7 @@ class Post(StatorModel): .aget(pk=self.pk) ) - ### Local creation ### + ### Local creation/editing ### @classmethod def create_local( @@ -269,21 +275,7 @@ class Post(StatorModel): ) -> "Post": with transaction.atomic(): # Find mentions in this post - mention_hits = cls.mention_regex.findall(content) - mentions = set() - for precursor, handle in mention_hits: - if "@" in handle: - username, domain = handle.split("@", 1) - else: - username = handle - domain = author.domain_id - identity = Identity.by_username_and_domain( - username=username, - domain=domain, - fetch=True, - ) - if identity is not None: - mentions.add(identity) + mentions = cls.mentions_from_content(content, author) if reply_to: mentions.add(reply_to.author) # Maintain local-only for replies @@ -307,6 +299,41 @@ class Post(StatorModel): post.save() return post + def edit_local( + self, + content: str, + summary: Optional[str] = None, + visibility: int = Visibilities.public, + ): + with transaction.atomic(): + # Strip all HTML and apply linebreaks filter + self.content = linebreaks_filter(strip_html(content)) + self.summary = summary or None + self.sensitive = bool(summary) + self.visibility = visibility + self.edited = timezone.now() + self.mentions.set(self.mentions_from_content(content, self.author)) + self.save() + + @classmethod + def mentions_from_content(cls, content, author) -> Set[Identity]: + mention_hits = cls.mention_regex.findall(content) + mentions = set() + for precursor, handle in mention_hits: + if "@" in handle: + username, domain = handle.split("@", 1) + else: + username = handle + domain = author.domain_id + identity = Identity.by_username_and_domain( + username=username, + domain=domain, + fetch=True, + ) + if identity is not None: + mentions.add(identity) + return mentions + ### ActivityPub (outbound) ### def to_ap(self) -> Dict: diff --git a/activities/views/posts.py b/activities/views/posts.py index 5d7b0c9..083df30 100644 --- a/activities/views/posts.py +++ b/activities/views/posts.py @@ -2,7 +2,6 @@ from django import forms from django.core.exceptions import PermissionDenied from django.http import JsonResponse from django.shortcuts import get_object_or_404, redirect, render -from django.utils import timezone from django.utils.decorators import method_decorator from django.views.generic import FormView, TemplateView, View @@ -13,6 +12,7 @@ from activities.models import ( PostStates, TimelineEvent, ) +from core.html import html_to_plaintext from core.ld import canonicalise from core.models import Config from users.decorators import identity_required @@ -218,7 +218,7 @@ class Compose(FormView): "id": self.post_obj.id, "reply_to": self.reply_to.pk if self.reply_to else "", "visibility": self.post_obj.visibility, - "text": self.post_obj.content, + "text": html_to_plaintext(self.post_obj.content), "content_warning": self.post_obj.summary, } ) @@ -236,11 +236,11 @@ class Compose(FormView): post_id = form.cleaned_data.get("id") if post_id: post = get_object_or_404(self.request.identity.posts, pk=post_id) - post.edited = timezone.now() - post.content = form.cleaned_data["text"] - post.summary = form.cleaned_data.get("content_warning") - post.visibility = form.cleaned_data["visibility"] - post.save() + post.edit_local( + content=form.cleaned_data["text"], + summary=form.cleaned_data.get("content_warning"), + visibility=form.cleaned_data["visibility"], + ) # Should there be a timeline event for edits? # E.g. "@user edited #123" diff --git a/core/html.py b/core/html.py index 3230284..dfb7beb 100644 --- a/core/html.py +++ b/core/html.py @@ -38,3 +38,15 @@ def strip_html(post_html: str) -> str: """ cleaner = bleach.Cleaner(tags=[], strip=True, filters=[LinkifyFilter]) return mark_safe(cleaner.clean(post_html)) + + +def html_to_plaintext(post_html: str) -> str: + """ + Tries to do the inverse of the linebreaks filter. + """ + # TODO: Handle HTML entities + # Remove all newlines, then replace br with a newline and /p with two (one comes from bleach) + post_html = post_html.replace("\n", "").replace("
", "\n").replace("

", "\n") + # Remove all other HTML and return + cleaner = bleach.Cleaner(tags=[], strip=True, filters=[]) + return cleaner.clean(post_html).strip() diff --git a/stator/runner.py b/stator/runner.py index c78437d..5cc0091 100644 --- a/stator/runner.py +++ b/stator/runner.py @@ -52,7 +52,7 @@ class StatorRunner: Config.system = await Config.aload_system() print(f"{self.handled} tasks processed so far") print("Running cleaning and scheduling") - await self.run_cleanup() + await self.run_scheduling() self.remove_completed_tasks() await self.fetch_and_process_tasks() @@ -75,7 +75,7 @@ class StatorRunner: print("Complete") return self.handled - async def run_cleanup(self): + async def run_scheduling(self): """ Do any transition cleanup tasks """ diff --git a/templates/activities/compose.html b/templates/activities/compose.html index e705f97..4809177 100644 --- a/templates/activities/compose.html +++ b/templates/activities/compose.html @@ -18,7 +18,7 @@ {% include "forms/_field.html" with field=form.visibility %}
- +
{% endblock %} diff --git a/templates/activities/post_delete.html b/templates/activities/post_delete.html index 1566399..47d283c 100644 --- a/templates/activities/post_delete.html +++ b/templates/activities/post_delete.html @@ -4,11 +4,11 @@ {% block content %}

Delete this post?

+ {% include "activities/_mini_post.html" %}
{% csrf_token %} Cancel
- {% include "activities/_post.html" %} {% endblock %} diff --git a/tests/core/test_html.py b/tests/core/test_html.py new file mode 100644 index 0000000..012a0ce --- /dev/null +++ b/tests/core/test_html.py @@ -0,0 +1,15 @@ +from core.html import html_to_plaintext + + +def test_html_to_plaintext(): + + assert html_to_plaintext("

Hi!

") == "Hi!" + assert html_to_plaintext("

Hi!
There

") == "Hi!\nThere" + assert ( + html_to_plaintext("

Hi!

\n\n

How are you?

") == "Hi!\n\nHow are you?" + ) + + assert ( + html_to_plaintext("

Hi!

\n\n

How are
you?

today

") + == "Hi!\n\nHow are\n you?\n\ntoday" + )