Initial reply-to feature

This commit is contained in:
Andrew Godwin 2022-11-24 15:17:32 -07:00
parent 4c00e11d63
commit ec634f2ad3
7 changed files with 65 additions and 6 deletions

View File

@ -3,6 +3,7 @@ from typing import Dict, Optional
import httpx import httpx
import urlman import urlman
from asgiref.sync import sync_to_async
from django.db import models, transaction from django.db import models, transaction
from django.template.defaultfilters import linebreaks_filter from django.template.defaultfilters import linebreaks_filter
from django.utils import timezone from django.utils import timezone
@ -42,6 +43,10 @@ class PostStates(StateGraph):
if post.visibility != Post.Visibilities.mentioned: if post.visibility != Post.Visibilities.mentioned:
async for follower in post.author.inbound_follows.select_related("source"): async for follower in post.author.inbound_follows.select_related("source"):
targets.add(follower.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 # Fan out to each one
for follow in targets: for follow in targets:
await FanOut.objects.acreate( await FanOut.objects.acreate(
@ -141,6 +146,7 @@ class Post(StatorModel):
action_unlike = "{view}unlike/" action_unlike = "{view}unlike/"
action_boost = "{view}boost/" action_boost = "{view}boost/"
action_unboost = "{view}unboost/" action_unboost = "{view}unboost/"
action_reply = "/compose/?reply_to={self.id}"
def get_scheme(self, url): def get_scheme(self, url):
return "https" return "https"
@ -164,6 +170,18 @@ class Post(StatorModel):
else: else:
return self.object_uri 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 ### ### Content cleanup and extraction ###
mention_regex = re.compile( mention_regex = re.compile(
@ -229,6 +247,7 @@ class Post(StatorModel):
content: str, content: str,
summary: Optional[str] = None, summary: Optional[str] = None,
visibility: int = Visibilities.public, visibility: int = Visibilities.public,
reply_to: Optional["Post"] = None,
) -> "Post": ) -> "Post":
with transaction.atomic(): with transaction.atomic():
# Find mentions in this post # Find mentions in this post
@ -247,6 +266,8 @@ class Post(StatorModel):
) )
if identity is not None: if identity is not None:
mentions.add(identity) mentions.add(identity)
if reply_to:
mentions.add(reply_to.author)
# Strip all HTML and apply linebreaks filter # Strip all HTML and apply linebreaks filter
content = linebreaks_filter(strip_html(content)) content = linebreaks_filter(strip_html(content))
# Make the Post object # Make the Post object
@ -257,6 +278,7 @@ class Post(StatorModel):
sensitive=bool(summary), sensitive=bool(summary),
local=True, local=True,
visibility=visibility, visibility=visibility,
in_reply_to=reply_to.object_uri if reply_to else None,
) )
post.object_uri = post.urls.object_uri post.object_uri = post.urls.object_uri
post.url = post.absolute_object_uri() post.url = post.absolute_object_uri()
@ -284,6 +306,8 @@ class Post(StatorModel):
} }
if self.summary: if self.summary:
value["summary"] = self.summary value["summary"] = self.summary
if self.in_reply_to:
value["inReplyTo"] = self.in_reply_to
# Mentions # Mentions
for mention in self.mentions.all(): for mention in self.mentions.all():
value["tag"].append( value["tag"].append(

View File

@ -143,6 +143,7 @@ class Compose(FormView):
), ),
help_text="Optional - Post will be hidden behind this text until clicked", 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): def clean_text(self):
text = self.cleaned_data.get("text") text = self.cleaned_data.get("text")
@ -155,10 +156,13 @@ class Compose(FormView):
) )
return text return text
def get_form_class(self): def get_initial(self):
form = super().get_form_class() initial = super().get_initial()
form.declared_fields["text"] if self.reply_to:
return form 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): def form_valid(self, form):
post = Post.create_local( post = Post.create_local(
@ -166,7 +170,27 @@ class Compose(FormView):
content=form.cleaned_data["text"], content=form.cleaned_data["text"],
summary=form.cleaned_data.get("content_warning"), summary=form.cleaned_data.get("content_warning"),
visibility=form.cleaned_data["visibility"], visibility=form.cleaned_data["visibility"],
reply_to=self.reply_to,
) )
# Add their own timeline event for immediate visibility # Add their own timeline event for immediate visibility
TimelineEvent.add_post(self.request.identity, post) TimelineEvent.add_post(self.request.identity, post)
return redirect("/") 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

View File

@ -37,12 +37,14 @@ Features planned for releases up to 1.0:
* Server defederation (blocking) * Server defederation (blocking)
* IP and email domain banning * IP and email domain banning
* Mastodon-compatible client API for use with apps * 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: Features that may make it into 1.0, or might be further out:
* Creating polls on posts, and handling received polls * Creating polls on posts, and handling received polls
* Filter system for Home timeline * Filter system for Home timeline
* Hashtag trending system with moderation * Hashtag trending system with moderation
* Mastodon-compatible account migration target/source
* Relay support * Relay support
Features on the long-term roadmap: Features on the long-term roadmap:

View File

@ -714,11 +714,11 @@ h1.identity small {
display: block; display: block;
float: right; float: right;
color: var(--color-text-duller); color: var(--color-text-duller);
width: 60px; width: 65px;
text-align: center; text-align: center;
background-color: var(--color-bg-main); background-color: var(--color-bg-main);
border-radius: 3px; border-radius: 3px;
padding: 3px 5px; padding: 3px 3px;
} }
.post time i { .post time i {

View File

@ -27,6 +27,7 @@
{% if request.identity %} {% if request.identity %}
<div class="actions"> <div class="actions">
{% include "activities/_reply.html" %}
{% include "activities/_like.html" %} {% include "activities/_like.html" %}
{% include "activities/_boost.html" %} {% include "activities/_boost.html" %}
</div> </div>

View File

@ -0,0 +1,4 @@
<a title="Reply" href="{{ post.urls.action_reply }}">
<i class="fa-solid fa-reply"></i>
</a>

View File

@ -7,6 +7,10 @@
{% csrf_token %} {% csrf_token %}
<fieldset> <fieldset>
<legend>Content</legend> <legend>Content</legend>
{% if reply_to %}
<p>Replying to <a href="{{ reply_to.urls.view }}">{{ reply_to }}</a></p>
{% endif %}
{{ form.reply_to }}
{% include "forms/_field.html" with field=form.text %} {% include "forms/_field.html" with field=form.text %}
{% include "forms/_field.html" with field=form.content_warning %} {% include "forms/_field.html" with field=form.content_warning %}
{% include "forms/_field.html" with field=form.visibility %} {% include "forms/_field.html" with field=form.visibility %}