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.