kopia lustrzana https://github.com/jointakahe/takahe
Merge a09914beb2
into d45f22c9c2
commit
2bdba8b6cd
|
@ -471,6 +471,7 @@ class Post(StatorModel):
|
||||||
"likes": self.stats.get("likes", 0) if self.stats else 0,
|
"likes": self.stats.get("likes", 0) if self.stats else 0,
|
||||||
"boosts": self.stats.get("boosts", 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,
|
"replies": self.stats.get("replies", 0) if self.stats else 0,
|
||||||
|
"reactions": self.stats.get("reactions", {}) if self.stats else {},
|
||||||
}
|
}
|
||||||
|
|
||||||
### Local creation/editing ###
|
### Local creation/editing ###
|
||||||
|
@ -610,12 +611,24 @@ class Post(StatorModel):
|
||||||
"likes": self.interactions.filter(
|
"likes": self.interactions.filter(
|
||||||
type=PostInteraction.Types.like,
|
type=PostInteraction.Types.like,
|
||||||
state__in=PostInteractionStates.group_active(),
|
state__in=PostInteractionStates.group_active(),
|
||||||
).count(),
|
)
|
||||||
|
.values("identity")
|
||||||
|
.distinct()
|
||||||
|
.count(), # This counts each user that's had any likes/reactions
|
||||||
"boosts": self.interactions.filter(
|
"boosts": self.interactions.filter(
|
||||||
type=PostInteraction.Types.boost,
|
type=PostInteraction.Types.boost,
|
||||||
state__in=PostInteractionStates.group_active(),
|
state__in=PostInteractionStates.group_active(),
|
||||||
).count(),
|
).count(),
|
||||||
"replies": Post.objects.filter(in_reply_to=self.object_uri).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:
|
if save:
|
||||||
self.save()
|
self.save()
|
||||||
|
|
|
@ -154,7 +154,7 @@ class PostInteraction(StatorModel):
|
||||||
)
|
)
|
||||||
|
|
||||||
# Used to store any interaction extra text value like the vote
|
# 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)
|
value = models.CharField(max_length=50, blank=True, null=True)
|
||||||
|
|
||||||
# When the activity was originally created (as opposed to when we received it)
|
# When the activity was originally created (as opposed to when we received it)
|
||||||
|
@ -392,6 +392,7 @@ class PostInteraction(StatorModel):
|
||||||
# Get the right type
|
# Get the right type
|
||||||
if data["type"].lower() == "like":
|
if data["type"].lower() == "like":
|
||||||
type = cls.Types.like
|
type = cls.Types.like
|
||||||
|
value = data.get("content") or data.get("_misskey_reaction")
|
||||||
elif data["type"].lower() == "announce":
|
elif data["type"].lower() == "announce":
|
||||||
type = cls.Types.boost
|
type = cls.Types.boost
|
||||||
elif (
|
elif (
|
||||||
|
|
|
@ -1,4 +1,5 @@
|
||||||
import logging
|
import logging
|
||||||
|
from types import EllipsisType
|
||||||
|
|
||||||
from activities.models import (
|
from activities.models import (
|
||||||
Post,
|
Post,
|
||||||
|
@ -38,7 +39,7 @@ class PostService:
|
||||||
def __init__(self, post: Post):
|
def __init__(self, post: Post):
|
||||||
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
|
Performs an interaction on this Post
|
||||||
"""
|
"""
|
||||||
|
@ -46,28 +47,39 @@ class PostService:
|
||||||
type=type,
|
type=type,
|
||||||
identity=identity,
|
identity=identity,
|
||||||
post=self.post,
|
post=self.post,
|
||||||
|
value=value,
|
||||||
)[0]
|
)[0]
|
||||||
if interaction.state not in PostInteractionStates.group_active():
|
if interaction.state not in PostInteractionStates.group_active():
|
||||||
interaction.transition_perform(PostInteractionStates.new)
|
interaction.transition_perform(PostInteractionStates.new)
|
||||||
self.post.calculate_stats()
|
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
|
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(
|
for interaction in PostInteraction.objects.filter(
|
||||||
type=type,
|
type=type,
|
||||||
identity=identity,
|
identity=identity,
|
||||||
post=self.post,
|
post=self.post,
|
||||||
|
**additional_fields,
|
||||||
):
|
):
|
||||||
interaction.transition_perform(PostInteractionStates.undone)
|
interaction.transition_perform(PostInteractionStates.undone)
|
||||||
|
|
||||||
self.post.calculate_stats()
|
self.post.calculate_stats()
|
||||||
|
|
||||||
def like_as(self, identity: Identity):
|
def like_as(self, identity: Identity, reaction: str | None = None):
|
||||||
self.interact_as(identity, PostInteraction.Types.like)
|
"""
|
||||||
|
Add a Like to the post, including reactions.
|
||||||
|
"""
|
||||||
|
self.interact_as(identity, PostInteraction.Types.like, value=reaction)
|
||||||
|
|
||||||
def unlike_as(self, identity: Identity):
|
def unlike_as(self, identity: Identity, reaction: str | None = None):
|
||||||
self.uninteract_as(identity, PostInteraction.Types.like)
|
self.uninteract_as(identity, PostInteraction.Types.like, value=reaction)
|
||||||
|
|
||||||
def boost_as(self, identity: Identity):
|
def boost_as(self, identity: Identity):
|
||||||
self.interact_as(identity, PostInteraction.Types.boost)
|
self.interact_as(identity, PostInteraction.Types.boost)
|
||||||
|
|
|
@ -78,10 +78,16 @@
|
||||||
<i class="fa-solid fa-reply"></i>
|
<i class="fa-solid fa-reply"></i>
|
||||||
<span class="like-count">{{ post.stats_with_defaults.replies|default:"0" }}</span>
|
<span class="like-count">{{ post.stats_with_defaults.replies|default:"0" }}</span>
|
||||||
</a>
|
</a>
|
||||||
<a title="Likes" class="no-action">
|
{% for reaction, count in post.stats_with_defaults.reactions.items %}
|
||||||
<i class="fa-solid fa-star"></i>
|
<a title="Reaction {{reaction}}" class="no-action">
|
||||||
<span class="like-count">{{ post.stats_with_defaults.likes|default:"0" }}</span>
|
{% if reaction %}
|
||||||
</a>
|
<span>{{reaction}}</span>
|
||||||
|
{% else %}
|
||||||
|
<i class="fa-solid fa-star"></i>
|
||||||
|
{% endif %}
|
||||||
|
<span class="like-count">{{count}}</span>
|
||||||
|
</a>
|
||||||
|
{% endfor %}
|
||||||
<a title="Boosts" class="no-action">
|
<a title="Boosts" class="no-action">
|
||||||
<i class="fa-solid fa-retweet"></i>
|
<i class="fa-solid fa-retweet"></i>
|
||||||
<span class="like-count">{{ post.stats_with_defaults.boosts|default:"0" }}</span>
|
<span class="like-count">{{ post.stats_with_defaults.boosts|default:"0" }}</span>
|
||||||
|
|
|
@ -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.
|
Ładowanie…
Reference in New Issue