summaryrefslogtreecommitdiffstats
path: root/activities
diff options
context:
space:
mode:
Diffstat (limited to 'activities')
-rw-r--r--activities/admin.py23
-rw-r--r--activities/models/__init__.py6
-rw-r--r--activities/models/fan_out.py35
-rw-r--r--activities/models/post.py50
-rw-r--r--activities/models/post_interaction.py112
-rw-r--r--activities/models/timeline_event.py17
-rw-r--r--activities/views/posts.py102
-rw-r--r--activities/views/timelines.py8
8 files changed, 322 insertions, 31 deletions
diff --git a/activities/admin.py b/activities/admin.py
index 947a596..a025230 100644
--- a/activities/admin.py
+++ b/activities/admin.py
@@ -6,25 +6,42 @@ from activities.models import FanOut, Post, PostInteraction, TimelineEvent
@admin.register(Post)
class PostAdmin(admin.ModelAdmin):
list_display = ["id", "state", "author", "created"]
- raw_id_fields = ["to", "mentions"]
+ raw_id_fields = ["to", "mentions", "author"]
actions = ["force_fetch"]
+ readonly_fields = ["created", "updated", "object_json"]
@admin.action(description="Force Fetch")
def force_fetch(self, request, queryset):
for instance in queryset:
instance.debug_fetch()
+ @admin.display(description="ActivityPub JSON")
+ def object_json(self, instance):
+ return instance.to_ap()
+
@admin.register(TimelineEvent)
class TimelineEventAdmin(admin.ModelAdmin):
list_display = ["id", "identity", "created", "type"]
- raw_id_fields = ["identity", "subject_post", "subject_identity"]
+ raw_id_fields = [
+ "identity",
+ "subject_post",
+ "subject_identity",
+ "subject_post_interaction",
+ ]
@admin.register(FanOut)
class FanOutAdmin(admin.ModelAdmin):
list_display = ["id", "state", "state_attempted", "type", "identity"]
- raw_id_fields = ["identity", "subject_post"]
+ raw_id_fields = ["identity", "subject_post", "subject_post_interaction"]
+ readonly_fields = ["created", "updated"]
+ actions = ["force_execution"]
+
+ @admin.action(description="Force Execution")
+ def force_execution(self, request, queryset):
+ for instance in queryset:
+ instance.transition_perform("new")
@admin.register(PostInteraction)
diff --git a/activities/models/__init__.py b/activities/models/__init__.py
index a0680ad..48ba879 100644
--- a/activities/models/__init__.py
+++ b/activities/models/__init__.py
@@ -1,4 +1,4 @@
-from .fan_out import FanOut # noqa
-from .post import Post # noqa
-from .post_interaction import PostInteraction # noqa
+from .fan_out import FanOut, FanOutStates # noqa
+from .post import Post, PostStates # noqa
+from .post_interaction import PostInteraction, PostInteractionStates # noqa
from .timeline_event import TimelineEvent # noqa
diff --git a/activities/models/fan_out.py b/activities/models/fan_out.py
index dbe86c0..771be19 100644
--- a/activities/models/fan_out.py
+++ b/activities/models/fan_out.py
@@ -38,6 +38,40 @@ class FanOutStates(StateGraph):
key_id=post.author.public_key_id,
)
return cls.sent
+ # Handle boosts/likes
+ elif fan_out.type == FanOut.Types.interaction:
+ interaction = await fan_out.subject_post_interaction.afetch_full()
+ if fan_out.identity.local:
+ # Make a timeline event directly
+ await sync_to_async(TimelineEvent.add_post_interaction)(
+ identity=fan_out.identity,
+ interaction=interaction,
+ )
+ else:
+ # Send it to the remote inbox
+ await HttpSignature.signed_request(
+ uri=fan_out.identity.inbox_uri,
+ body=canonicalise(interaction.to_ap()),
+ private_key=interaction.identity.private_key,
+ key_id=interaction.identity.public_key_id,
+ )
+ # 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:
+ # Delete any local timeline events
+ await sync_to_async(TimelineEvent.delete_post_interaction)(
+ identity=fan_out.identity,
+ interaction=interaction,
+ )
+ else:
+ # Send an undo to the remote inbox
+ await HttpSignature.signed_request(
+ uri=fan_out.identity.inbox_uri,
+ body=canonicalise(interaction.to_undo_ap()),
+ private_key=interaction.identity.private_key,
+ key_id=interaction.identity.public_key_id,
+ )
else:
raise ValueError(f"Cannot fan out with type {fan_out.type}")
@@ -50,6 +84,7 @@ class FanOut(StatorModel):
class Types(models.TextChoices):
post = "post"
interaction = "interaction"
+ undo_interaction = "undo_interaction"
state = StateField(FanOutStates)
diff --git a/activities/models/post.py b/activities/models/post.py
index 74b335b..22e6412 100644
--- a/activities/models/post.py
+++ b/activities/models/post.py
@@ -2,7 +2,7 @@ from typing import Dict, Optional
import httpx
import urlman
-from django.db import models
+from django.db import models, transaction
from django.utils import timezone
from activities.models.fan_out import FanOut
@@ -99,7 +99,12 @@ class Post(StatorModel):
class urls(urlman.Urls):
view = "{self.author.urls.view}posts/{self.id}/"
- object_uri = "{self.author.urls.actor}posts/{self.id}/"
+ view_nice = "{self.author.urls.view_nice}posts/{self.id}/"
+ object_uri = "{self.author.actor_uri}posts/{self.id}/"
+ action_like = "{view}like/"
+ action_unlike = "{view}unlike/"
+ action_boost = "{view}boost/"
+ action_unboost = "{view}unboost/"
def get_scheme(self, url):
return "https"
@@ -130,16 +135,17 @@ class Post(StatorModel):
def create_local(
cls, author: Identity, content: str, summary: Optional[str] = None
) -> "Post":
- post = cls.objects.create(
- author=author,
- content=content,
- summary=summary or None,
- sensitive=bool(summary),
- local=True,
- )
- post.object_uri = post.author.actor_uri + f"posts/{post.id}/"
- post.url = post.object_uri
- post.save()
+ with transaction.atomic():
+ post = cls.objects.create(
+ author=author,
+ content=content,
+ summary=summary or None,
+ sensitive=bool(summary),
+ local=True,
+ )
+ post.object_uri = post.urls.object_uri
+ post.url = post.urls.view_nice
+ post.save()
return post
### ActivityPub (outbound) ###
@@ -179,7 +185,7 @@ class Post(StatorModel):
"content": self.safe_content,
"to": "as:Public",
"as:sensitive": self.sensitive,
- "url": self.urls.view.full(), # type: ignore
+ "url": self.urls.view_nice if self.local else self.url,
}
if self.summary:
value["summary"] = self.summary
@@ -257,7 +263,7 @@ class Post(StatorModel):
create=True,
update=True,
)
- raise ValueError(f"Cannot find Post with URI {object_uri}")
+ raise cls.DoesNotExist(f"Cannot find Post with URI {object_uri}")
@classmethod
def handle_create_ap(cls, data):
@@ -275,6 +281,22 @@ class Post(StatorModel):
# Force it into fanned_out as it's not ours
post.transition_perform(PostStates.fanned_out)
+ @classmethod
+ def handle_delete_ap(cls, data):
+ """
+ Handles an incoming create request
+ """
+ # Find our post by ID if we have one
+ try:
+ post = cls.by_object_uri(data["object"]["id"])
+ except cls.DoesNotExist:
+ # It's already been deleted
+ return
+ # Ensure the actor on the request authored the post
+ if not post.author.actor_uri == data["actor"]:
+ raise ValueError("Actor on delete does not match object")
+ post.delete()
+
def debug_fetch(self):
"""
Fetches the Post from its original URL again and updates us with it
diff --git a/activities/models/post_interaction.py b/activities/models/post_interaction.py
index 151ab45..ea95cdf 100644
--- a/activities/models/post_interaction.py
+++ b/activities/models/post_interaction.py
@@ -14,9 +14,13 @@ from users.models.identity import Identity
class PostInteractionStates(StateGraph):
new = State(try_interval=300)
- fanned_out = State()
+ fanned_out = State(externally_progressed=True)
+ undone = State(try_interval=300)
+ undone_fanned_out = State()
new.transitions_to(fanned_out)
+ fanned_out.transitions_to(undone)
+ undone.transitions_to(undone_fanned_out)
@classmethod
async def handle_new(cls, instance: "PostInteraction"):
@@ -31,26 +35,74 @@ class PostInteractionStates(StateGraph):
):
if follow.source.local or follow.target.local:
await FanOut.objects.acreate(
- identity_id=follow.source_id,
type=FanOut.Types.interaction,
- subject_post=interaction,
+ identity_id=follow.source_id,
+ subject_post=interaction.post,
+ subject_post_interaction=interaction,
)
# Like: send a copy to the original post author only
elif interaction.type == interaction.Types.like:
await FanOut.objects.acreate(
- identity_id=interaction.post.author_id,
type=FanOut.Types.interaction,
- subject_post=interaction,
+ identity_id=interaction.post.author_id,
+ subject_post=interaction.post,
+ subject_post_interaction=interaction,
)
else:
raise ValueError("Cannot fan out unknown type")
- # And one for themselves if they're local
- if interaction.identity.local:
+ # And one for themselves if they're local and it's a boost
+ if (
+ interaction.type == PostInteraction.Types.boost
+ and interaction.identity.local
+ ):
await FanOut.objects.acreate(
identity_id=interaction.identity_id,
type=FanOut.Types.interaction,
- subject_post=interaction,
+ subject_post=interaction.post,
+ subject_post_interaction=interaction,
+ )
+ return cls.fanned_out
+
+ @classmethod
+ async def handle_undone(cls, instance: "PostInteraction"):
+ """
+ Creates all needed fan-out objects to undo a PostInteraction.
+ """
+ interaction = await instance.afetch_full()
+ # Undo Boost: send a copy to all people who follow this user
+ if interaction.type == interaction.Types.boost:
+ async for follow in interaction.identity.inbound_follows.select_related(
+ "source", "target"
+ ):
+ if follow.source.local or follow.target.local:
+ await FanOut.objects.acreate(
+ type=FanOut.Types.undo_interaction,
+ identity_id=follow.source_id,
+ subject_post=interaction.post,
+ subject_post_interaction=interaction,
+ )
+ # Undo Like: send a copy to the original post author only
+ elif interaction.type == interaction.Types.like:
+ await FanOut.objects.acreate(
+ type=FanOut.Types.undo_interaction,
+ identity_id=interaction.post.author_id,
+ subject_post=interaction.post,
+ subject_post_interaction=interaction,
+ )
+ else:
+ raise ValueError("Cannot fan out unknown type")
+ # And one for themselves if they're local and it's a boost
+ if (
+ interaction.type == PostInteraction.Types.boost
+ and interaction.identity.local
+ ):
+ await FanOut.objects.acreate(
+ identity_id=interaction.identity_id,
+ type=FanOut.Types.undo_interaction,
+ subject_post=interaction.post,
+ subject_post_interaction=interaction,
)
+ return cls.undone_fanned_out
class PostInteraction(StatorModel):
@@ -95,6 +147,35 @@ class PostInteraction(StatorModel):
class Meta:
index_together = [["type", "identity", "post"]]
+ ### Display helpers ###
+
+ @classmethod
+ def get_post_interactions(cls, posts, identity):
+ """
+ Returns a dict of {interaction_type: set(post_ids)} for all the posts
+ and the given identity, for use in templates.
+ """
+ # Bulk-fetch any interactions
+ ids_with_interaction_type = cls.objects.filter(
+ identity=identity,
+ post_id__in=[post.pk for post in posts],
+ type__in=[cls.Types.like, cls.Types.boost],
+ state__in=[PostInteractionStates.new, PostInteractionStates.fanned_out],
+ ).values_list("post_id", "type")
+ # Make it into the return dict
+ result = {}
+ for post_id, interaction_type in ids_with_interaction_type:
+ result.setdefault(interaction_type, set()).add(post_id)
+ return result
+
+ @classmethod
+ def get_event_interactions(cls, events, identity):
+ """
+ Returns a dict of {interaction_type: set(post_ids)} for all the posts
+ within the events and the given identity, for use in templates.
+ """
+ return cls.get_post_interactions([e.subject_post for e in events], identity)
+
### Async helpers ###
async def afetch_full(self):
@@ -111,6 +192,9 @@ class PostInteraction(StatorModel):
"""
Returns the AP JSON for this object
"""
+ # Create an object URI if we don't have one
+ if self.object_uri is None:
+ self.object_uri = self.identity.actor_uri + f"#{self.type}/{self.id}"
if self.type == self.Types.boost:
value = {
"type": "Announce",
@@ -132,6 +216,18 @@ class PostInteraction(StatorModel):
raise ValueError("Cannot turn into AP")
return value
+ def to_undo_ap(self) -> Dict:
+ """
+ Returns the AP JSON to undo this object
+ """
+ object = self.to_ap()
+ return {
+ "id": object["id"] + "/undo",
+ "type": "Undo",
+ "actor": self.identity.actor_uri,
+ "object": object,
+ }
+
### ActivityPub (inbound) ###
@classmethod
diff --git a/activities/models/timeline_event.py b/activities/models/timeline_event.py
index 6dba32c..29dec19 100644
--- a/activities/models/timeline_event.py
+++ b/activities/models/timeline_event.py
@@ -114,3 +114,20 @@ class TimelineEvent(models.Model):
subject_identity_id=interaction.identity_id,
subject_post_interaction=interaction,
)[0]
+
+ @classmethod
+ def delete_post_interaction(cls, identity, interaction):
+ if interaction.type == interaction.Types.like:
+ cls.objects.filter(
+ identity=identity,
+ type=cls.Types.liked,
+ subject_post_id=interaction.post_id,
+ subject_identity_id=interaction.identity_id,
+ ).delete()
+ elif interaction.type == interaction.Types.boost:
+ cls.objects.filter(
+ identity=identity,
+ type__in=[cls.Types.boosted, cls.Types.boost],
+ subject_post_id=interaction.post_id,
+ subject_identity_id=interaction.identity_id,
+ ).delete()
diff --git a/activities/views/posts.py b/activities/views/posts.py
new file mode 100644
index 0000000..ece7cf3
--- /dev/null
+++ b/activities/views/posts.py
@@ -0,0 +1,102 @@
+from django.shortcuts import get_object_or_404, redirect, render
+from django.utils.decorators import method_decorator
+from django.views.generic import TemplateView, View
+
+from activities.models import PostInteraction, PostInteractionStates
+from users.decorators import identity_required
+from users.shortcuts import by_handle_or_404
+
+
+class Post(TemplateView):
+
+ template_name = "activities/post.html"
+
+ def get_context_data(self, handle, post_id):
+ identity = by_handle_or_404(self.request, handle, local=False)
+ post = get_object_or_404(identity.posts, pk=post_id)
+ return {
+ "identity": identity,
+ "post": post,
+ "interactions": PostInteraction.get_post_interactions(
+ [post],
+ self.request.identity,
+ ),
+ }
+
+
+@method_decorator(identity_required, name="dispatch")
+class Like(View):
+ """
+ Adds/removes a like from the current identity to the post
+ """
+
+ undo = False
+
+ def post(self, request, handle, post_id):
+ identity = by_handle_or_404(self.request, handle, local=False)
+ post = get_object_or_404(identity.posts, pk=post_id)
+ if self.undo:
+ # Undo any likes on the post
+ for interaction in PostInteraction.objects.filter(
+ type=PostInteraction.Types.like,
+ identity=request.identity,
+ post=post,
+ ):
+ interaction.transition_perform(PostInteractionStates.undone)
+ else:
+ # Make a like on this post if we didn't already
+ PostInteraction.objects.get_or_create(
+ type=PostInteraction.Types.like,
+ identity=request.identity,
+ post=post,
+ )
+ # Return either a redirect or a HTMX snippet
+ if request.htmx:
+ return render(
+ request,
+ "activities/_like.html",
+ {
+ "post": post,
+ "interactions": {"like": set() if self.undo else {post.pk}},
+ },
+ )
+ return redirect(post.urls.view)
+
+
+@method_decorator(identity_required, name="dispatch")
+class Boost(View):
+ """
+ Adds/removes a boost from the current identity to the post
+ """
+
+ undo = False
+
+ def post(self, request, handle, post_id):
+ identity = by_handle_or_404(self.request, handle, local=False)
+ post = get_object_or_404(identity.posts, pk=post_id)
+ if self.undo:
+ # Undo any boosts on the post
+ for interaction in PostInteraction.objects.filter(
+ type=PostInteraction.Types.boost,
+ identity=request.identity,
+ post=post,
+ ):
+ interaction.transition_perform(PostInteractionStates.undone)
+ else:
+ # Make a boost on this post if we didn't already
+ PostInteraction.objects.get_or_create(
+ type=PostInteraction.Types.boost,
+ identity=request.identity,
+ post=post,
+ )
+ # Return either a redirect or a HTMX snippet
+ if request.htmx:
+ return render(
+ request,
+ "activities/_boost.html",
+ {
+ "post": post,
+ "interactions": {"boost": set() if self.undo else {post.pk}},
+ },
+ )
+ return redirect(post.urls.view)
diff --git a/activities/views/timelines.py b/activities/views/timelines.py
index 9be988d..c59c3b6 100644
--- a/activities/views/timelines.py
+++ b/activities/views/timelines.py
@@ -4,7 +4,7 @@ from django.template.defaultfilters import linebreaks_filter
from django.utils.decorators import method_decorator
from django.views.generic import FormView, TemplateView
-from activities.models import Post, TimelineEvent
+from activities.models import Post, PostInteraction, TimelineEvent
from users.decorators import identity_required
@@ -33,7 +33,7 @@ class Home(FormView):
def get_context_data(self):
context = super().get_context_data()
- context["events"] = (
+ context["events"] = list(
TimelineEvent.objects.filter(
identity=self.request.identity,
type__in=[TimelineEvent.Types.post, TimelineEvent.Types.boost],
@@ -41,7 +41,9 @@ class Home(FormView):
.select_related("subject_post", "subject_post__author")
.order_by("-created")[:100]
)
-
+ context["interactions"] = PostInteraction.get_event_interactions(
+ context["events"], self.request.identity
+ )
context["current_page"] = "home"
return context