diff --git a/activities/models/post.py b/activities/models/post.py
index 3b08de29a..a5c0a1b43 100644
--- a/activities/models/post.py
+++ b/activities/models/post.py
@@ -471,6 +471,7 @@ def stats_with_defaults(self):
"likes": self.stats.get("likes", 0) if self.stats else 0,
"boosts": self.stats.get("boosts", 0) if self.stats else 0,
"replies": self.stats.get("replies", 0) if self.stats else 0,
+ "reactions": self.stats.get("reactions", {}) if self.stats else {},
}
### Local creation/editing ###
@@ -610,12 +611,24 @@ def calculate_stats(self, save=True):
"likes": self.interactions.filter(
type=PostInteraction.Types.like,
state__in=PostInteractionStates.group_active(),
- ).count(),
+ )
+ .values("identity")
+ .distinct()
+ .count(), # This counts each user that's had any likes/reactions
"boosts": self.interactions.filter(
type=PostInteraction.Types.boost,
state__in=PostInteractionStates.group_active(),
).count(),
"replies": Post.objects.filter(in_reply_to=self.object_uri).count(),
+ "reactions": {
+ row["value"] or "": row["count"]
+ for row in self.interactions.filter(
+ type=PostInteraction.Types.like,
+ state__in=PostInteractionStates.group_active(),
+ )
+ .values("value")
+ .annotate(count=models.Count("identity"))
+ },
}
if save:
self.save()
diff --git a/activities/models/post_interaction.py b/activities/models/post_interaction.py
index 13ea8242b..950adc60d 100644
--- a/activities/models/post_interaction.py
+++ b/activities/models/post_interaction.py
@@ -154,7 +154,7 @@ class Types(models.TextChoices):
)
# Used to store any interaction extra text value like the vote
- # in the question/poll case
+ # in the question/poll case, or the reaction
value = models.CharField(max_length=50, blank=True, null=True)
# When the activity was originally created (as opposed to when we received it)
@@ -392,6 +392,7 @@ def by_ap(cls, data, create=False) -> "PostInteraction":
# Get the right type
if data["type"].lower() == "like":
type = cls.Types.like
+ value = data.get("content") or data.get("_misskey_reaction")
elif data["type"].lower() == "announce":
type = cls.Types.boost
elif (
diff --git a/activities/services/post.py b/activities/services/post.py
index dbfc837b4..68f90e405 100644
--- a/activities/services/post.py
+++ b/activities/services/post.py
@@ -1,4 +1,5 @@
import logging
+from types import EllipsisType
from activities.models import (
Post,
@@ -38,7 +39,7 @@ def queryset(cls):
def __init__(self, post: Post):
self.post = post
- def interact_as(self, identity: Identity, type: str):
+ def interact_as(self, identity: Identity, type: str, value: str | None = None):
"""
Performs an interaction on this Post
"""
@@ -46,28 +47,39 @@ def interact_as(self, identity: Identity, type: str):
type=type,
identity=identity,
post=self.post,
+ value=value,
)[0]
if interaction.state not in PostInteractionStates.group_active():
interaction.transition_perform(PostInteractionStates.new)
self.post.calculate_stats()
- def uninteract_as(self, identity, type):
+ def uninteract_as(self, identity, type, value: str | None | EllipsisType = ...):
"""
Undoes an interaction on this Post
"""
+ # Only search by value if it was actually given
+ additional_fields = {}
+ if value is not ...:
+ additional_fields["value"] = value
+
for interaction in PostInteraction.objects.filter(
type=type,
identity=identity,
post=self.post,
+ **additional_fields,
):
interaction.transition_perform(PostInteractionStates.undone)
+
self.post.calculate_stats()
- def like_as(self, identity: Identity):
- self.interact_as(identity, PostInteraction.Types.like)
+ def like_as(self, identity: Identity, reaction: str | None = None):
+ """
+ Add a Like to the post, including reactions.
+ """
+ self.interact_as(identity, PostInteraction.Types.like, value=reaction)
- def unlike_as(self, identity: Identity):
- self.uninteract_as(identity, PostInteraction.Types.like)
+ def unlike_as(self, identity: Identity, reaction: str | None = None):
+ self.uninteract_as(identity, PostInteraction.Types.like, value=reaction)
def boost_as(self, identity: Identity):
self.interact_as(identity, PostInteraction.Types.boost)
diff --git a/templates/activities/_post.html b/templates/activities/_post.html
index a12eea9ef..1ff8a2079 100644
--- a/templates/activities/_post.html
+++ b/templates/activities/_post.html
@@ -78,10 +78,16 @@
{{ post.stats_with_defaults.replies|default:"0" }}
-
-
- {{ post.stats_with_defaults.likes|default:"0" }}
-
+ {% for reaction, count in post.stats_with_defaults.reactions.items %}
+
+ {% if reaction %}
+ {{reaction}}
+ {% else %}
+
+ {% endif %}
+ {{count}}
+
+ {% endfor %}
{{ post.stats_with_defaults.boosts|default:"0" }}
diff --git a/tests/activities/models/test_reactions.py b/tests/activities/models/test_reactions.py
new file mode 100644
index 000000000..328864e54
--- /dev/null
+++ b/tests/activities/models/test_reactions.py
@@ -0,0 +1,364 @@
+import pytest
+
+from activities.models import Post, TimelineEvent
+from activities.services import PostService
+from users.models import Identity, InboxMessage
+
+
+@pytest.mark.django_db
+@pytest.mark.parametrize("local", [True, False])
+@pytest.mark.parametrize("reaction", ["\U0001F607"])
+def test_react_notification(
+ identity: Identity,
+ other_identity: Identity,
+ remote_identity: Identity,
+ stator,
+ local: bool,
+ reaction: str,
+):
+ """
+ Ensures that a reaction of a local Post notifies its author.
+
+ This mostly ensures that basic reaction flows happen.
+ """
+ post = Post.create_local(author=identity, content="I love birds!")
+ if local:
+ PostService(post).like_as(other_identity, reaction)
+ else:
+ message = {
+ "id": "test",
+ "type": "Like",
+ "actor": remote_identity.actor_uri,
+ "object": post.object_uri,
+ "content": reaction,
+ }
+ InboxMessage.objects.create(message=message)
+
+ interactor = other_identity if local else remote_identity
+
+ # Run stator thrice - to receive the post, make fanouts and then process them
+ stator.run_single_cycle()
+ stator.run_single_cycle()
+ stator.run_single_cycle()
+
+ # Verify we got an event
+ event = TimelineEvent.objects.filter(
+ type=TimelineEvent.Types.liked, identity=identity
+ ).first()
+ assert event
+ assert event.subject_identity == interactor
+ assert event.subject_post_interaction.value == reaction
+
+
+@pytest.mark.django_db
+@pytest.mark.parametrize("local", [True, False])
+@pytest.mark.parametrize("reaction", ["\U0001F607"])
+def test_react_duplicate(
+ identity: Identity,
+ other_identity: Identity,
+ remote_identity: Identity,
+ stator,
+ local: bool,
+ reaction: str,
+):
+ """
+ Ensures that if we receive the same reaction from the same actor multiple times,
+ only one notification and interaction are produced.
+ """
+ post = Post.create_local(author=identity, content="I love birds!")
+ for _ in range(3):
+ if local:
+ PostService(post).like_as(other_identity, reaction)
+ else:
+ message = {
+ "id": "test",
+ "type": "Like",
+ "actor": remote_identity.actor_uri,
+ "object": post.object_uri,
+ "content": reaction,
+ }
+ InboxMessage.objects.create(message=message)
+
+ interactor = other_identity if local else remote_identity
+
+ # Running stator 3 times for each interaction. Not sure what's the right number.
+ for _ in range(9):
+ stator.run_single_cycle()
+
+ # Verify we got an event
+ events = TimelineEvent.objects.filter(
+ type=TimelineEvent.Types.liked, identity=identity
+ ).all()
+
+ assert len(events) == 1
+ (event,) = events
+
+ assert event.subject_identity == interactor
+ assert event.subject_post_interaction.value == reaction
+
+
+@pytest.mark.django_db
+@pytest.mark.parametrize("local", [True, False])
+@pytest.mark.parametrize("reaction", ["\U0001F607"])
+def test_react_undo(
+ identity: Identity,
+ other_identity: Identity,
+ remote_identity: Identity,
+ stator,
+ local: bool,
+ reaction: str,
+):
+ """
+ Ensures basic un-reacting.
+ """
+ post = Post.create_local(author=identity, content="I love birds!")
+ if local:
+ PostService(post).like_as(other_identity, reaction)
+ else:
+ message = {
+ "id": "test",
+ "type": "Like",
+ "actor": remote_identity.actor_uri,
+ "object": post.object_uri,
+ "content": reaction,
+ }
+ InboxMessage.objects.create(message=message)
+
+ # Run stator thrice - to receive the post, make fanouts and then process them
+ stator.run_single_cycle()
+ stator.run_single_cycle()
+ stator.run_single_cycle()
+
+ # Verify we got an event
+ events = TimelineEvent.objects.filter(
+ type=TimelineEvent.Types.liked, identity=identity
+ ).all()
+ assert len(events) == 1
+
+ if local:
+ PostService(post).unlike_as(other_identity, reaction)
+ else:
+ message = {
+ "id": "test/undo",
+ "type": "Undo",
+ "actor": remote_identity.actor_uri,
+ "object": {
+ "id": "test",
+ "type": "Like",
+ "actor": remote_identity.actor_uri,
+ "object": post.object_uri,
+ "content": reaction,
+ },
+ }
+ InboxMessage.objects.create(message=message)
+
+ # Run stator thrice - to receive the post, make fanouts and then process them
+ stator.run_single_cycle()
+ stator.run_single_cycle()
+ stator.run_single_cycle()
+
+ # Verify the event was removed.
+ events = TimelineEvent.objects.filter(
+ type=TimelineEvent.Types.liked, identity=identity
+ ).all()
+ assert len(events) == 0
+
+
+@pytest.mark.django_db
+@pytest.mark.parametrize("local", [True, False])
+def test_react_undo_mismatched(
+ identity: Identity,
+ other_identity: Identity,
+ remote_identity: Identity,
+ stator,
+ local: bool,
+):
+ """
+ Ensures that un-reacting deletes the right reaction.
+ """
+ post = Post.create_local(author=identity, content="I love birds!")
+ if local:
+ PostService(post).like_as(other_identity, "foo")
+ else:
+ message = {
+ "id": "test",
+ "type": "Like",
+ "actor": remote_identity.actor_uri,
+ "object": post.object_uri,
+ "content": "foo",
+ }
+ InboxMessage.objects.create(message=message)
+
+ # Run stator thrice - to receive the post, make fanouts and then process them
+ stator.run_single_cycle()
+ stator.run_single_cycle()
+ stator.run_single_cycle()
+
+ # Verify we got an event
+ events = TimelineEvent.objects.filter(
+ type=TimelineEvent.Types.liked, identity=identity
+ ).all()
+ assert len(events) == 1
+
+ if local:
+ PostService(post).unlike_as(other_identity, "bar")
+ else:
+ message = {
+ "id": "test/undo",
+ "type": "Undo",
+ "actor": remote_identity.actor_uri,
+ "object": {
+ # AstraLuma: I'm actually unsure if this test should use the same or different ID.
+ "id": "test2",
+ "type": "Like",
+ "actor": remote_identity.actor_uri,
+ "object": post.object_uri,
+ "content": "bar",
+ },
+ }
+ InboxMessage.objects.create(message=message)
+
+ # Run stator thrice - to receive the post, make fanouts and then process them
+ stator.run_single_cycle()
+ stator.run_single_cycle()
+ stator.run_single_cycle()
+
+ # Verify the event was removed.
+ events = TimelineEvent.objects.filter(
+ type=TimelineEvent.Types.liked, identity=identity
+ ).all()
+ assert len(events) == 1
+
+
+@pytest.mark.django_db
+@pytest.mark.parametrize("local", [True, False])
+@pytest.mark.parametrize("reaction", ["\U0001F607"])
+def test_react_stats(
+ identity: Identity,
+ other_identity: Identity,
+ remote_identity: Identity,
+ stator,
+ local: bool,
+ reaction: str,
+):
+ """
+ Checks basic post stats generation
+ """
+ post = Post.create_local(author=identity, content="I love birds!")
+ if local:
+ PostService(post).like_as(other_identity, reaction)
+ else:
+ message = {
+ "id": "test",
+ "type": "Like",
+ "actor": remote_identity.actor_uri,
+ "object": post.object_uri,
+ "content": reaction,
+ }
+ InboxMessage.objects.create(message=message)
+
+ # Run stator thrice - to receive the post, make fanouts and then process them
+ stator.run_single_cycle()
+ stator.run_single_cycle()
+ stator.run_single_cycle()
+
+ post.refresh_from_db()
+
+ assert "reactions" in post.stats
+ assert post.stats["reactions"] == {reaction: 1}
+
+
+@pytest.mark.django_db
+@pytest.mark.parametrize("local", [True, False])
+def test_react_stats_multiple(
+ identity: Identity,
+ other_identity: Identity,
+ remote_identity: Identity,
+ stator,
+ local: bool,
+):
+ """
+ Ensures that multiple reactions get aggregated correctly.
+
+ Basically, if the same person leaves multiple reactions, aggregate all of them into one Like.
+ """
+ post = Post.create_local(author=identity, content="I love birds!")
+ for i, reaction in enumerate("abc"):
+ if local:
+ PostService(post).like_as(other_identity, reaction)
+ else:
+ message = {
+ "id": f"test{i}",
+ "type": "Like",
+ "actor": remote_identity.actor_uri,
+ "object": post.object_uri,
+ "content": reaction,
+ }
+ InboxMessage.objects.create(message=message)
+
+ # Run stator thrice - to receive the post, make fanouts and then process them
+ stator.run_single_cycle()
+ stator.run_single_cycle()
+ stator.run_single_cycle()
+
+ post.refresh_from_db()
+
+ assert post.stats["reactions"] == {"a": 1, "b": 1, "c": 1}
+ assert post.stats["likes"] == 1
+
+
+@pytest.mark.django_db
+@pytest.mark.parametrize("local", [True, False])
+def test_react_stats_mixed(
+ identity: Identity,
+ other_identity: Identity,
+ remote_identity: Identity,
+ stator,
+ local: bool,
+):
+ """
+ Ensures that mixed Likes and Reactions get aggregated
+ """
+ post = Post.create_local(author=identity, content="I love birds!")
+ for i, reaction in enumerate("abc"):
+ if local:
+ PostService(post).like_as(other_identity, reaction)
+ else:
+ message = {
+ "id": f"test{i}",
+ "type": "Like",
+ "actor": remote_identity.actor_uri,
+ "object": post.object_uri,
+ "content": reaction,
+ }
+ InboxMessage.objects.create(message=message)
+
+ if local:
+ PostService(post).like_as(other_identity)
+ else:
+ message = {
+ "id": "test",
+ "type": "Like",
+ "actor": remote_identity.actor_uri,
+ "object": post.object_uri,
+ }
+ InboxMessage.objects.create(message=message)
+
+ # Run stator thrice - to receive the post, make fanouts and then process them
+ for _ in range(4):
+ stator.run_single_cycle()
+
+ post.refresh_from_db()
+
+ assert post.stats["reactions"] == {"a": 1, "b": 1, "c": 1, "": 1}
+ assert post.stats["likes"] == 1
+
+
+# TODO: Test that multiple reactions can be added and deleted correctly
+
+# TODO: How should plain likes and reactions from the same source be handled?
+# Specifically if we receive an unlike without a specific reaction.
+
+# Hm, If Misskey is single-reaction, will it send Like interactions for changes
+# in reaction? Then we're expected to overwrite that users previous interaction
+# rather than create a new one.