From 4c3cae337ccbad31788fb34e0322cef55bf099bf Mon Sep 17 00:00:00 2001 From: Jamie Bliss Date: Thu, 9 Nov 2023 11:08:12 -0500 Subject: [PATCH 1/5] Store any incoming reactions PostInteraction.value --- activities/models/post_interaction.py | 3 +- activities/services/post.py | 24 ++++++++--- tests/activities/models/test_reactions.py | 49 +++++++++++++++++++++++ 3 files changed, 69 insertions(+), 7 deletions(-) create mode 100644 tests/activities/models/test_reactions.py 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/tests/activities/models/test_reactions.py b/tests/activities/models/test_reactions.py new file mode 100644 index 000000000..0c0b0fdc1 --- /dev/null +++ b/tests/activities/models/test_reactions.py @@ -0,0 +1,49 @@ +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 like of a local Post notifies its author + """ + 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) + + # Implement any blocks + 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 From 42b0b5831a4f1c66a271bd288a252bf792c304be Mon Sep 17 00:00:00 2001 From: Jamie Bliss Date: Thu, 9 Nov 2023 13:40:26 -0500 Subject: [PATCH 2/5] Add more reaction tests --- tests/activities/models/test_reactions.py | 195 +++++++++++++++++++++- 1 file changed, 193 insertions(+), 2 deletions(-) diff --git a/tests/activities/models/test_reactions.py b/tests/activities/models/test_reactions.py index 0c0b0fdc1..40313ffd9 100644 --- a/tests/activities/models/test_reactions.py +++ b/tests/activities/models/test_reactions.py @@ -17,7 +17,9 @@ def test_react_notification( reaction: str, ): """ - Ensures that a like of a local Post notifies its author + 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: @@ -32,7 +34,6 @@ def test_react_notification( } InboxMessage.objects.create(message=message) - # Implement any blocks interactor = other_identity if local else remote_identity # Run stator thrice - to receive the post, make fanouts and then process them @@ -47,3 +48,193 @@ def test_react_notification( 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 + + +# 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. From b3e67ffe3aa61047be950f0f07cad09fe767269a Mon Sep 17 00:00:00 2001 From: Jamie Bliss Date: Thu, 9 Nov 2023 14:51:09 -0500 Subject: [PATCH 3/5] Implement reaction aggregation. --- activities/models/post.py | 14 ++++- tests/activities/models/test_reactions.py | 77 +++++++++++++++++++++++ 2 files changed, 90 insertions(+), 1 deletion(-) diff --git a/activities/models/post.py b/activities/models/post.py index 3b08de29a..1e7073c1c 100644 --- a/activities/models/post.py +++ b/activities/models/post.py @@ -610,12 +610,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"]: 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/tests/activities/models/test_reactions.py b/tests/activities/models/test_reactions.py index 40313ffd9..ea073a1a4 100644 --- a/tests/activities/models/test_reactions.py +++ b/tests/activities/models/test_reactions.py @@ -230,6 +230,83 @@ def test_react_undo_mismatched( 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 + + # TODO: Test that multiple reactions can be added and deleted correctly # TODO: How should plain likes and reactions from the same source be handled? From 54bddb0dd79b514434d0f74ed42c3c7b10885b4b Mon Sep 17 00:00:00 2001 From: Jamie Bliss Date: Fri, 10 Nov 2023 14:54:44 -0500 Subject: [PATCH 4/5] Update UI to display reactions --- activities/models/post.py | 3 ++- templates/activities/_post.html | 14 ++++++++++---- 2 files changed, 12 insertions(+), 5 deletions(-) diff --git a/activities/models/post.py b/activities/models/post.py index 1e7073c1c..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 ### @@ -620,7 +621,7 @@ def calculate_stats(self, save=True): ).count(), "replies": Post.objects.filter(in_reply_to=self.object_uri).count(), "reactions": { - row["value"]: row["count"] + row["value"] or "": row["count"] for row in self.interactions.filter( type=PostInteraction.Types.like, state__in=PostInteractionStates.group_active(), 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 @@ - - - - + {% for reaction, count in post.stats_with_defaults.reactions.items %} + + {% if reaction %} + {{reaction}} + {% else %} + + {% endif %} + + + {% endfor %} From a09914beb2b54858a213e461577bb7564ca62f77 Mon Sep 17 00:00:00 2001 From: Jamie Bliss Date: Fri, 10 Nov 2023 14:42:53 -0500 Subject: [PATCH 5/5] Add test for mixed reactions handling --- tests/activities/models/test_reactions.py | 47 +++++++++++++++++++++++ 1 file changed, 47 insertions(+) diff --git a/tests/activities/models/test_reactions.py b/tests/activities/models/test_reactions.py index ea073a1a4..328864e54 100644 --- a/tests/activities/models/test_reactions.py +++ b/tests/activities/models/test_reactions.py @@ -307,6 +307,53 @@ def test_react_stats_multiple( 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?