summaryrefslogtreecommitdiffstats
path: root/activities
diff options
context:
space:
mode:
authorMichael Manfre2022-11-27 13:09:46 -0500
committerGitHub2022-11-27 11:09:46 -0700
commit6c7ddedd342553b53dd98c8de9cbe9e8e2e8cd7c (patch)
treee34059bca5e13a8a614687face1153d63e7f5654 /activities
parent263af996d8ed05e37ef5a62c6ed240216a6eb67b (diff)
downloadtakahe-6c7ddedd342553b53dd98c8de9cbe9e8e2e8cd7c.tar.gz
takahe-6c7ddedd342553b53dd98c8de9cbe9e8e2e8cd7c.tar.bz2
takahe-6c7ddedd342553b53dd98c8de9cbe9e8e2e8cd7c.zip
Post editing
Diffstat (limited to 'activities')
-rw-r--r--activities/models/fan_out.py86
-rw-r--r--activities/models/post.py40
-rw-r--r--activities/views/posts.py85
3 files changed, 160 insertions, 51 deletions
diff --git a/activities/models/fan_out.py b/activities/models/fan_out.py
index 64df929..a86e30a 100644
--- a/activities/models/fan_out.py
+++ b/activities/models/fan_out.py
@@ -17,11 +17,15 @@ class FanOutStates(StateGraph):
"""
Sends the fan-out to the right inbox.
"""
+ LOCAL_IDENTITY = True
+ REMOTE_IDENTITY = False
+
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:
+
+ match (fan_out.type, fan_out.identity.local):
+ # Handle creating/updating local posts
+ case (FanOut.Types.post | FanOut.Types.post_edited, LOCAL_IDENTITY):
+ 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
# of the people mentioned.
@@ -44,63 +48,91 @@ class FanOutStates(StateGraph):
identity=fan_out.identity,
post=post,
)
- else:
+
+ # Handle sending remote posts create
+ case (FanOut.Types.post, REMOTE_IDENTITY):
+ 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:
+
+ # Handle sending remote posts update
+ case (FanOut.Types.post_edited, REMOTE_IDENTITY):
+ 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_update_ap()),
+ )
+
+ # Handle deleting local posts
+ case (FanOut.Types.post_deleted, LOCAL_IDENTITY):
+ 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()
+
+ # Handle sending remote post deletes
+ case (FanOut.Types.post_deleted, REMOTE_IDENTITY):
+ post = await fan_out.subject_post.afetch_full()
# 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()
- if fan_out.identity.local:
+
+ # Handle local boosts/likes
+ case (FanOut.Types.interaction, LOCAL_IDENTITY):
+ interaction = await fan_out.subject_post_interaction.afetch_full()
# Make a timeline event directly
await sync_to_async(TimelineEvent.add_post_interaction)(
identity=fan_out.identity,
interaction=interaction,
)
- else:
+
+ # Handle sending remote boosts/likes
+ case (FanOut.Types.interaction, REMOTE_IDENTITY):
+ interaction = await fan_out.subject_post_interaction.afetch_full()
# Send it to the remote inbox
await interaction.identity.signed_request(
method="post",
uri=fan_out.identity.inbox_uri,
body=canonicalise(interaction.to_ap()),
)
- # Handle undoing boosts/likes
- elif fan_out.type == FanOut.Types.undo_interaction:
- interaction = await fan_out.subject_post_interaction.afetch_full()
- if fan_out.identity.local:
+
+ # Handle undoing local boosts/likes
+ case (FanOut.Types.undo_interaction, LOCAL_IDENTITY): # noqa:F841
+ interaction = await fan_out.subject_post_interaction.afetch_full()
+
# Delete any local timeline events
await sync_to_async(TimelineEvent.delete_post_interaction)(
identity=fan_out.identity,
interaction=interaction,
)
- else:
+
+ # Handle sending remote undoing boosts/likes
+ case (FanOut.Types.undo_interaction, REMOTE_IDENTITY): # noqa:F841
+ interaction = await fan_out.subject_post_interaction.afetch_full()
# Send an undo to the remote inbox
await interaction.identity.signed_request(
method="post",
uri=fan_out.identity.inbox_uri,
body=canonicalise(interaction.to_undo_ap()),
)
- else:
- raise ValueError(f"Cannot fan out with type {fan_out.type}")
+
+ case _:
+ raise ValueError(
+ f"Cannot fan out with type {fan_out.type} local={fan_out.identity.local}"
+ )
+
return cls.sent
diff --git a/activities/models/post.py b/activities/models/post.py
index f75c526..23194b3 100644
--- a/activities/models/post.py
+++ b/activities/models/post.py
@@ -22,9 +22,17 @@ class PostStates(StateGraph):
deleted = State(try_interval=300)
deleted_fanned_out = State()
+ edited = State(try_interval=300)
+ edited_fanned_out = State(externally_progressed=True)
+
new.transitions_to(fanned_out)
fanned_out.transitions_to(deleted)
+ fanned_out.transitions_to(edited)
+
deleted.transitions_to(deleted_fanned_out)
+ edited.transitions_to(edited_fanned_out)
+ edited_fanned_out.transitions_to(edited)
+ edited_fanned_out.transitions_to(deleted)
@classmethod
async def handle_new(cls, instance: "Post"):
@@ -56,6 +64,21 @@ class PostStates(StateGraph):
)
return cls.deleted_fanned_out
+ @classmethod
+ async def handle_edited(cls, instance: "Post"):
+ """
+ Creates all needed fan-out objects for an edited Post.
+ """
+ post = await instance.afetch_full()
+ # Fan out to each target
+ for follow in await post.aget_targets():
+ await FanOut.objects.acreate(
+ identity=follow,
+ type=FanOut.Types.post_edited,
+ subject_post=post,
+ )
+ return cls.edited_fanned_out
+
class Post(StatorModel):
"""
@@ -140,6 +163,7 @@ class Post(StatorModel):
action_boost = "{view}boost/"
action_unboost = "{view}unboost/"
action_delete = "{view}delete/"
+ action_edit = "{view}edit/"
action_reply = "/compose/?reply_to={self.id}"
def get_scheme(self, url):
@@ -305,6 +329,8 @@ class Post(StatorModel):
value["summary"] = self.summary
if self.in_reply_to:
value["inReplyTo"] = self.in_reply_to
+ if self.edited:
+ value["updated"] = format_ld_date(self.edited)
# Mentions
for mention in self.mentions.all():
value["tag"].append(
@@ -336,6 +362,20 @@ class Post(StatorModel):
"object": object,
}
+ def to_update_ap(self):
+ """
+ Returns the AP JSON to update this object
+ """
+ object = self.to_ap()
+ return {
+ "to": object["to"],
+ "cc": object.get("cc", []),
+ "type": "Update",
+ "id": self.object_uri + "#update",
+ "actor": self.author.actor_uri,
+ "object": object,
+ }
+
def to_delete_ap(self):
"""
Returns the AP JSON to create this object
diff --git a/activities/views/posts.py b/activities/views/posts.py
index 59b1f56..5d7b0c9 100644
--- a/activities/views/posts.py
+++ b/activities/views/posts.py
@@ -1,6 +1,8 @@
from django import forms
-from django.http import Http404, JsonResponse
+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
@@ -143,11 +145,11 @@ class Delete(TemplateView):
template_name = "activities/post_delete.html"
def dispatch(self, request, handle, post_id):
+ # Make sure the request identity owns the post!
+ if handle != request.identity.handle:
+ raise PermissionDenied("Post author is not requestor")
self.identity = by_handle_or_404(self.request, handle, local=False)
self.post_obj = get_object_or_404(self.identity.posts, pk=post_id)
- # Make sure the request identity owns the post!
- if self.post_obj.author != request.identity:
- raise Http404("Post author is not requestor")
return super().dispatch(request)
def get_context_data(self):
@@ -164,6 +166,10 @@ class Compose(FormView):
template_name = "activities/compose.html"
class form_class(forms.Form):
+ id = forms.IntegerField(
+ required=False,
+ widget=forms.HiddenInput(),
+ )
text = forms.CharField(
widget=forms.Textarea(
@@ -206,33 +212,64 @@ class Compose(FormView):
def get_initial(self):
initial = super().get_initial()
- initial[
- "visibility"
- ] = self.request.identity.config_identity.default_post_visibility
- if self.reply_to:
- initial["reply_to"] = self.reply_to.pk
- initial["visibility"] = self.reply_to.visibility
- initial["text"] = f"@{self.reply_to.author.handle} "
+ if self.post_obj:
+ initial.update(
+ {
+ "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,
+ "content_warning": self.post_obj.summary,
+ }
+ )
+ else:
+ initial[
+ "visibility"
+ ] = self.request.identity.config_identity.default_post_visibility
+ if self.reply_to:
+ initial["reply_to"] = self.reply_to.pk
+ initial["visibility"] = self.reply_to.visibility
+ initial["text"] = f"@{self.reply_to.author.handle} "
return initial
def form_valid(self, form):
- post = Post.create_local(
- author=self.request.identity,
- 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)
+ 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()
+
+ # Should there be a timeline event for edits?
+ # E.g. "@user edited #123"
+
+ post.transition_perform(PostStates.edited)
+ else:
+ post = Post.create_local(
+ author=self.request.identity,
+ 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):
+ def dispatch(self, request, handle=None, post_id=None, *args, **kwargs):
+ self.post_obj = None
+ if handle and post_id:
+ # Make sure the request identity owns the post!
+ if handle != request.identity.handle:
+ raise PermissionDenied("Post author is not requestor")
+
+ self.post_obj = get_object_or_404(request.identity.posts, pk=post_id)
+
# 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"
- )
+ reply_to_id = request.POST.get("reply_to") or request.GET.get("reply_to")
if reply_to_id:
try:
self.reply_to = Post.objects.get(pk=reply_to_id)