From ec634f2ad382f659eedae884154d0db9a2a006b2 Mon Sep 17 00:00:00 2001 From: Andrew Godwin Date: Thu, 24 Nov 2022 15:17:32 -0700 Subject: Initial reply-to feature --- activities/models/post.py | 24 ++++++++++++++++++++++++ activities/views/posts.py | 32 ++++++++++++++++++++++++++++---- docs/features.rst | 2 ++ static/css/style.css | 4 ++-- templates/activities/_post.html | 1 + templates/activities/_reply.html | 4 ++++ templates/activities/compose.html | 4 ++++ 7 files changed, 65 insertions(+), 6 deletions(-) create mode 100644 templates/activities/_reply.html diff --git a/activities/models/post.py b/activities/models/post.py index 876d422..c86ec6a 100644 --- a/activities/models/post.py +++ b/activities/models/post.py @@ -3,6 +3,7 @@ from typing import Dict, Optional import httpx import urlman +from asgiref.sync import sync_to_async from django.db import models, transaction from django.template.defaultfilters import linebreaks_filter from django.utils import timezone @@ -42,6 +43,10 @@ class PostStates(StateGraph): 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: await FanOut.objects.acreate( @@ -141,6 +146,7 @@ class Post(StatorModel): action_unlike = "{view}unlike/" action_boost = "{view}boost/" action_unboost = "{view}unboost/" + action_reply = "/compose/?reply_to={self.id}" def get_scheme(self, url): return "https" @@ -164,6 +170,18 @@ class Post(StatorModel): else: return self.object_uri + def in_reply_to_post(self) -> Optional["Post"]: + """ + Returns the actual Post object we're replying to, if we can find it + """ + return ( + Post.objects.filter(object_uri=self.in_reply_to) + .select_related("author") + .first() + ) + + ain_reply_to_post = sync_to_async(in_reply_to_post) + ### Content cleanup and extraction ### mention_regex = re.compile( @@ -229,6 +247,7 @@ class Post(StatorModel): content: str, summary: Optional[str] = None, visibility: int = Visibilities.public, + reply_to: Optional["Post"] = None, ) -> "Post": with transaction.atomic(): # Find mentions in this post @@ -247,6 +266,8 @@ class Post(StatorModel): ) if identity is not None: mentions.add(identity) + if reply_to: + mentions.add(reply_to.author) # Strip all HTML and apply linebreaks filter content = linebreaks_filter(strip_html(content)) # Make the Post object @@ -257,6 +278,7 @@ class Post(StatorModel): sensitive=bool(summary), local=True, visibility=visibility, + in_reply_to=reply_to.object_uri if reply_to else None, ) post.object_uri = post.urls.object_uri post.url = post.absolute_object_uri() @@ -284,6 +306,8 @@ class Post(StatorModel): } if self.summary: value["summary"] = self.summary + if self.in_reply_to: + value["inReplyTo"] = self.in_reply_to # Mentions for mention in self.mentions.all(): value["tag"].append( diff --git a/activities/views/posts.py b/activities/views/posts.py index fce17e3..a53d401 100644 --- a/activities/views/posts.py +++ b/activities/views/posts.py @@ -143,6 +143,7 @@ class Compose(FormView): ), help_text="Optional - Post will be hidden behind this text until clicked", ) + reply_to = forms.CharField(widget=forms.HiddenInput(), required=False) def clean_text(self): text = self.cleaned_data.get("text") @@ -155,10 +156,13 @@ class Compose(FormView): ) return text - def get_form_class(self): - form = super().get_form_class() - form.declared_fields["text"] - return form + def get_initial(self): + initial = super().get_initial() + if self.reply_to: + initial["reply_to"] = self.reply_to.pk + initial["visibility"] = Post.Visibilities.unlisted + initial["text"] = f"@{self.reply_to.author.handle} " + return initial def form_valid(self, form): post = Post.create_local( @@ -166,7 +170,27 @@ class Compose(FormView): content=form.cleaned_data["text"], summary=form.cleaned_data.get("content_warning"), visibility=form.cleaned_data["visibility"], + reply_to=self.reply_to, ) # Add their own timeline event for immediate visibility TimelineEvent.add_post(self.request.identity, post) return redirect("/") + + def dispatch(self, request, *args, **kwargs): + # Grab the reply-to post info now + self.reply_to = None + reply_to_id = self.request.POST.get("reply_to") or self.request.GET.get( + "reply_to" + ) + if reply_to_id: + try: + self.reply_to = Post.objects.get(pk=reply_to_id) + except Post.DoesNotExist: + pass + # Keep going with normal rendering + return super().dispatch(request, *args, **kwargs) + + def get_context_data(self, **kwargs): + context = super().get_context_data(**kwargs) + context["reply_to"] = self.reply_to + return context diff --git a/docs/features.rst b/docs/features.rst index 0d2e3ac..0cc178a 100644 --- a/docs/features.rst +++ b/docs/features.rst @@ -37,12 +37,14 @@ Features planned for releases up to 1.0: * Server defederation (blocking) * IP and email domain banning * Mastodon-compatible client API for use with apps +* RSS feeds for users' public posts Features that may make it into 1.0, or might be further out: * Creating polls on posts, and handling received polls * Filter system for Home timeline * Hashtag trending system with moderation +* Mastodon-compatible account migration target/source * Relay support Features on the long-term roadmap: diff --git a/static/css/style.css b/static/css/style.css index 5c966a7..642057f 100644 --- a/static/css/style.css +++ b/static/css/style.css @@ -714,11 +714,11 @@ h1.identity small { display: block; float: right; color: var(--color-text-duller); - width: 60px; + width: 65px; text-align: center; background-color: var(--color-bg-main); border-radius: 3px; - padding: 3px 5px; + padding: 3px 3px; } .post time i { diff --git a/templates/activities/_post.html b/templates/activities/_post.html index 11e4494..dbdc99a 100644 --- a/templates/activities/_post.html +++ b/templates/activities/_post.html @@ -27,6 +27,7 @@ {% if request.identity %}