Initial reply-to feature
This commit is contained in:
parent
4c00e11d63
commit
ec634f2ad3
|
@ -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(
|
||||||
|
|
|
@ -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
|
||||||
|
|
|
@ -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:
|
||||||
|
|
|
@ -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 {
|
||||||
|
|
|
@ -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>
|
||||||
|
|
|
@ -0,0 +1,4 @@
|
||||||
|
|
||||||
|
<a title="Reply" href="{{ post.urls.action_reply }}">
|
||||||
|
<i class="fa-solid fa-reply"></i>
|
||||||
|
</a>
|
|
@ -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 %}
|
||||||
|
|
Loading…
Reference in New Issue