use post interactions for pins and fan out Add/Remove activities

pull/561/head
Christof Dorner 2023-04-24 11:03:40 +02:00
rodzic ea72ee1e95
commit 3975c37906
13 zmienionych plików z 208 dodań i 147 usunięć

Wyświetl plik

@ -0,0 +1,26 @@
# Generated by Django 4.1.7 on 2023-04-24 08:04
from django.db import migrations, models
class Migration(migrations.Migration):
dependencies = [
("activities", "0014_post_content_vector_gin"),
]
operations = [
migrations.AlterField(
model_name="postinteraction",
name="type",
field=models.CharField(
choices=[
("like", "Like"),
("boost", "Boost"),
("vote", "Vote"),
("pin", "Pin"),
],
max_length=100,
),
),
]

Wyświetl plik

@ -163,22 +163,24 @@ class FanOutStates(StateGraph):
interaction=interaction,
)
# Handle sending remote boosts/likes/votes
# Handle sending remote boosts/likes/votes/pins
case (FanOut.Types.interaction, False):
interaction = await fan_out.subject_post_interaction.afetch_full()
# Send it to the remote inbox
try:
if interaction.type == interaction.Types.vote:
body = interaction.to_ap()
elif interaction.type == interaction.Types.pin:
body = interaction.to_add_ap()
else:
body = interaction.to_create_ap()
await interaction.identity.signed_request(
method="post",
uri=(
fan_out.identity.shared_inbox_uri
or fan_out.identity.inbox_uri
),
body=canonicalise(
interaction.to_create_ap()
if interaction.type == interaction.Types.vote
else interaction.to_ap()
),
body=canonicalise(body),
)
except httpx.RequestError:
return
@ -193,18 +195,22 @@ class FanOutStates(StateGraph):
interaction=interaction,
)
# Handle sending remote undoing boosts/likes
# Handle sending remote undoing boosts/likes/pins
case (FanOut.Types.undo_interaction, False): # noqa:F841
interaction = await fan_out.subject_post_interaction.afetch_full()
# Send an undo to the remote inbox
try:
if interaction.type == interaction.Types.pin:
body = interaction.to_remove_ap()
else:
body = interaction.to_undo_ap()
await interaction.identity.signed_request(
method="post",
uri=(
fan_out.identity.shared_inbox_uri
or fan_out.identity.inbox_uri
),
body=canonicalise(interaction.to_undo_ap()),
body=canonicalise(body),
)
except httpx.RequestError:
return

Wyświetl plik

@ -1160,8 +1160,7 @@ class Post(StatorModel):
if interactions:
value["favourited"] = self.pk in interactions.get("like", [])
value["reblogged"] = self.pk in interactions.get("boost", [])
value["pinned"] = self.pk in interactions.get("pin", [])
if bookmarks:
value["bookmarked"] = self.pk in bookmarks
if identity and identity.pinned:
value["pinned"] = self.object_uri in identity.pinned
return value

Wyświetl plik

@ -34,8 +34,12 @@ class PostInteractionStates(StateGraph):
interaction = await instance.afetch_full()
# Boost: send a copy to all people who follow this user (limiting
# to just local follows if it's a remote boost)
if interaction.type == interaction.Types.boost:
for target in await interaction.aget_boost_targets():
# Pin: send Add activity to all people who follow this user
if (
interaction.type == interaction.Types.boost
or interaction.type == interaction.Types.pin
):
for target in await interaction.aget_targets():
await FanOut.objects.acreate(
type=FanOut.Types.interaction,
identity=target,
@ -85,7 +89,11 @@ class PostInteractionStates(StateGraph):
"""
interaction = await instance.afetch_full()
# Undo Boost: send a copy to all people who follow this user
if interaction.type == interaction.Types.boost:
# Undo Pin: send a Remove activity to all people who follow this user
if (
interaction.type == interaction.Types.boost
or interaction.type == interaction.Types.pin
):
async for follow in interaction.identity.inbound_follows.select_related(
"source", "target"
):
@ -129,6 +137,7 @@ class PostInteraction(StatorModel):
like = "like"
boost = "boost"
vote = "vote"
pin = "pin"
id = models.BigIntegerField(
primary_key=True,
@ -186,7 +195,7 @@ class PostInteraction(StatorModel):
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],
type__in=[cls.Types.like, cls.Types.boost, cls.Types.pin],
state__in=[PostInteractionStates.new, PostInteractionStates.fanned_out],
).values_list("post_id", "type")
# Make it into the return dict
@ -215,18 +224,22 @@ class PostInteraction(StatorModel):
"identity", "post", "post__author"
).aget(pk=self.pk)
async def aget_boost_targets(self) -> Iterable[Identity]:
async def aget_targets(self) -> Iterable[Identity]:
"""
Returns an iterable with Identities of followers that have unique
shared_inbox among each other to be used as target to the boost
shared_inbox among each other to be used as target.
When interaction is boost, only boost follows are considered,
for pins all followers are considered.
"""
# Start including the post author
targets = {self.post.author}
query = self.identity.inbound_follows.active()
# Include all followers that are following the boosts
async for follow in self.identity.inbound_follows.active().filter(
boosts=True
).select_related("source"):
if self.type == self.Types.boost:
query = query.filter(boosts=True)
async for follow in query.select_related("source"):
targets.add(follow.source)
# Fetch the full blocks and remove them as targets
@ -326,7 +339,7 @@ class PostInteraction(StatorModel):
"inReplyTo": self.post.object_uri,
"attributedTo": self.identity.actor_uri,
}
else:
elif self.type == self.Types.pin:
raise ValueError("Cannot turn into AP")
return value
@ -356,6 +369,28 @@ class PostInteraction(StatorModel):
"object": object,
}
def to_add_ap(self):
"""
Returns the AP JSON to add a pin interaction to the featured collection
"""
return {
"type": "Add",
"actor": self.identity.actor_uri,
"object": self.post.object_uri,
"target": self.identity.actor_uri + "collections/featured/",
}
def to_remove_ap(self):
"""
Returns the AP JSON to remove a pin interaction from the featured collection
"""
return {
"type": "Remove",
"actor": self.identity.actor_uri,
"object": self.post.object_uri,
"target": self.identity.actor_uri + "collections/featured/",
}
### ActivityPub (inbound) ###
@classmethod

Wyświetl plik

@ -142,3 +142,22 @@ class PostService:
),
PostInteractionStates.undone,
)
def pin_as(self, identity: Identity):
if identity != self.post.author:
raise ValueError("Not the author of this post")
if self.post.visibility == Post.Visibilities.mentioned:
raise ValueError("Cannot pin a mentioned-only post")
if (
PostInteraction.objects.filter(
type=PostInteraction.Types.pin,
identity=identity,
).count()
>= 5
):
raise ValueError("Maximum number of pins already reached")
self.interact_as(identity, PostInteraction.Types.pin)
def unpin_as(self, identity: Identity):
self.uninteract_as(identity, PostInteraction.Types.pin)

Wyświetl plik

@ -92,16 +92,18 @@ class TimelineService:
.order_by("-id")
)
def pinned(self, identity: Identity) -> models.QuerySet[Post]:
def identity_pinned(self) -> models.QuerySet[Post]:
"""
Return all pinned posts for an identity
Return all pinned posts that are publicly visible for an identity
"""
return (
PostService.queryset()
.filter(author=identity)
.filter(object_uri__in=(identity.pinned or []))
.unlisted(include_replies=True)
.order_by("-id")
.public()
.filter(
interactions__identity=self.identity,
interactions__type=PostInteraction.Types.pin,
interactions__state__in=PostInteractionStates.group_active(),
)
)
def likes(self) -> models.QuerySet[Post]:

Wyświetl plik

@ -5,7 +5,7 @@ from django.http import HttpRequest
from django.shortcuts import get_object_or_404
from hatchway import ApiResponse, QueryOrBody, api_view
from activities.models import Post
from activities.models import Post, PostInteraction, PostInteractionStates
from activities.services import SearchService
from api import schemas
from api.decorators import scope_required
@ -200,7 +200,10 @@ def account_statuses(
.order_by("-created")
)
if pinned:
queryset = queryset.filter(object_uri__in=identity.pinned)
queryset = queryset.filter(
interactions__type=PostInteraction.Types.pin,
interactions__state__in=PostInteractionStates.group_active(),
)
if only_media:
queryset = queryset.filter(attachments__pk__isnull=False)
if tagged:

Wyświetl plik

@ -18,7 +18,6 @@ from api import schemas
from api.decorators import scope_required
from api.pagination import MastodonPaginator, PaginatingApiResponse, PaginationResult
from core.models import Config
from users.services import IdentityService
class PostPollSchema(Schema):
@ -347,8 +346,11 @@ def unbookmark_status(request, id: str) -> schemas.Status:
def pin_status(request, id: str) -> schemas.Status:
post = post_for_id(request, id)
try:
IdentityService(request.identity).pin_post(post)
return schemas.Status.from_post(post, identity=request.identity)
PostService(post).pin_as(request.identity)
interactions = PostInteraction.get_post_interactions([post], request.identity)
return schemas.Status.from_post(
post, identity=request.identity, interactions=interactions
)
except ValueError as e:
raise ApiError(422, str(e))
@ -357,5 +359,8 @@ def pin_status(request, id: str) -> schemas.Status:
@api_view.post
def unpin_status(request, id: str) -> schemas.Status:
post = post_for_id(request, id)
IdentityService(request.identity).unpin_post(post)
return schemas.Status.from_post(post, identity=request.identity)
PostService(post).unpin_as(request.identity)
interactions = PostInteraction.get_post_interactions([post], request.identity)
return schemas.Status.from_post(
post, identity=request.identity, interactions=interactions
)

Wyświetl plik

@ -1,6 +1,6 @@
import pytest
from activities.models import Post
from activities.models import Post, PostInteraction
from activities.services import PostService
from users.models import Identity
@ -35,3 +35,78 @@ def test_post_context(identity: Identity, config_system):
ancestors, descendants = PostService(post3).context(None)
assert ancestors == [post2, post1]
assert descendants == []
@pytest.mark.django_db
def test_pin_as(identity: Identity, identity2: Identity, config_system):
post = Post.create_local(
author=identity,
content="Hello world",
)
mentioned_post = Post.create_local(
author=identity,
content="mentioned-only post",
visibility=Post.Visibilities.mentioned,
)
service = PostService(post)
assert (
PostInteraction.objects.filter(
identity=identity, type=PostInteraction.Types.pin
).count()
== 0
)
service.pin_as(identity)
assert (
PostInteraction.objects.filter(
identity=identity, post=post, type=PostInteraction.Types.pin
).count()
== 1
)
# pinning same post is a no-op
service.pin_as(identity)
assert (
PostInteraction.objects.filter(
identity=identity, post=post, type=PostInteraction.Types.pin
).count()
== 1
)
# Identity can only pin their own posts
with pytest.raises(ValueError):
service.pin_as(identity2)
assert (
PostInteraction.objects.filter(
identity=identity2, post=post, type=PostInteraction.Types.pin
).count()
== 0
)
# Cannot pin a post with mentioned-only visibility
with pytest.raises(ValueError):
PostService(mentioned_post).pin_as(identity)
assert (
PostInteraction.objects.filter(
identity=identity2, post=mentioned_post, type=PostInteraction.Types.pin
).count()
== 0
)
# Can only pin max 5 posts
for i in range(5):
new_post = Post.create_local(
author=identity2,
content=f"post {i}",
)
PostService(new_post).pin_as(identity2)
post = Post.create_local(author=identity2, content="post 6")
with pytest.raises(ValueError):
PostService(post).pin_as(identity2)
assert (
PostInteraction.objects.filter(
identity=identity2, type=PostInteraction.Types.pin
).count()
== 5
)

Wyświetl plik

@ -1,73 +0,0 @@
import pytest
from activities.models import Post
from users.models import Identity
from users.services import IdentityService
@pytest.mark.django_db
def test_pin_post(identity: Identity, identity2: Identity, config_system):
post = Post.create_local(
author=identity,
content="Hello world",
)
mentioned_post = Post.create_local(
author=identity,
content="mentioned-only post",
visibility=Post.Visibilities.mentioned,
)
service = IdentityService(identity)
assert identity.pinned is None
service.pin_post(post)
assert identity.pinned == [post.object_uri]
# pinning same post should be a no-op
service.pin_post(post)
assert identity.pinned == [post.object_uri]
# Identity can only pin their own posts
with pytest.raises(ValueError):
IdentityService(identity2).pin_post(post)
# Cannot pin a post with mentioned-only visibility
with pytest.raises(ValueError):
service.pin_post(mentioned_post)
# Can only pin max 5 posts
identity.pinned = [
"http://instance/user/post-1",
"http://instance/user/post-2",
"http://instance/user/post-3",
"http://instance/user/post-4",
"http://instance/user/post-5",
]
with pytest.raises(ValueError):
service.pin_post(post)
@pytest.mark.django_db
def test_unpin_post(identity: Identity, config_system):
post = Post.create_local(
author=identity,
content="Hello world",
)
other_post = Post.create_local(
author=identity,
content="Other post",
)
service = IdentityService(identity)
assert identity.pinned is None
service.pin_post(post)
assert identity.pinned == [post.object_uri]
service.unpin_post(post)
assert identity.pinned == []
# unpinning unpinned post results in no-op
service.pin_post(post)
assert identity.pinned == [post.object_uri]
service.unpin_post(other_post)
assert identity.pinned == [post.object_uri]

Wyświetl plik

@ -1,7 +1,7 @@
from django.db import models
from django.template.defaultfilters import linebreaks_filter
from activities.models import FanOut, Post
from activities.models import FanOut
from core.files import resize_image
from core.html import FediverseHtmlParser
from users.models import (
@ -242,37 +242,6 @@ class IdentityService:
resize_image(file, size=(1500, 500)),
)
def pin_post(self, post: Post):
"""
Pins a post to the identitiy's timeline
"""
if self.identity != post.author:
raise ValueError("Not the author of this post")
if post.visibility == Post.Visibilities.mentioned:
raise ValueError("Cannot pin a mentioned-only post")
if not self.identity.pinned:
self.identity.pinned = []
if post.object_uri in self.identity.pinned:
return
if len(self.identity.pinned) >= 5:
raise ValueError("Maximum number of pins already reached")
self.identity.pinned.append(post.object_uri)
self.identity.save()
def unpin_post(self, post: Post):
"""
Removes a post from the identity's pinned posts
"""
if not self.identity.pinned:
return
if post.object_uri not in self.identity.pinned:
return
self.identity.pinned.remove(post.object_uri)
self.identity.save()
@classmethod
def handle_internal_add_follow(cls, payload):
"""

Wyświetl plik

@ -9,6 +9,7 @@ from django.views.decorators.csrf import csrf_exempt
from django.views.generic import View
from activities.models import Post
from activities.services import TimelineService
from core import exceptions
from core.decorators import cache_page
from core.ld import canonicalise
@ -236,11 +237,7 @@ class FeaturedCollection(View):
)
if not self.identity.local:
raise Http404("Not a local identity")
posts = list(
self.identity.posts.not_hidden()
.public(include_replies=True)
.filter(object_uri__in=self.identity.pinned)
)
posts = list(TimelineService(self.identity).identity_pinned())
return JsonResponse(
canonicalise(
{

Wyświetl plik

@ -71,9 +71,7 @@ class ViewIdentity(ListView):
context["identity"] = self.identity
context["public_styling"] = True
context["post_count"] = self.identity.posts.count()
context["pinned_posts"] = TimelineService(self.request.identity).pinned(
self.identity
)
context["pinned_posts"] = TimelineService(self.identity).identity_pinned()
if self.identity.config_identity.visible_follows:
context["followers_count"] = self.identity.inbound_follows.filter(
state__in=FollowStates.group_active()