kopia lustrzana https://github.com/jointakahe/takahe
use post interactions for pins and fan out Add/Remove activities
rodzic
ea72ee1e95
commit
3975c37906
|
@ -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,
|
||||
),
|
||||
),
|
||||
]
|
|
@ -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
|
||||
|
|
|
@ -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
|
||||
|
|
|
@ -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
|
||||
|
|
|
@ -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)
|
||||
|
|
|
@ -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]:
|
||||
|
|
|
@ -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:
|
||||
|
|
|
@ -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
|
||||
)
|
||||
|
|
|
@ -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
|
||||
)
|
||||
|
|
|
@ -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]
|
|
@ -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):
|
||||
"""
|
||||
|
|
|
@ -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(
|
||||
{
|
||||
|
|
|
@ -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()
|
||||
|
|
Ładowanie…
Reference in New Issue