mirror of
https://github.com/jointakahe/takahe.git
synced 2024-11-25 08:41:00 +00:00
Pinned posts (#561)
This commit is contained in:
parent
744c2825d9
commit
d6c9ba0819
24 changed files with 586 additions and 31 deletions
26
activities/migrations/0015_alter_postinteraction_type.py
Normal file
26
activities/migrations/0015_alter_postinteraction_type.py
Normal file
|
@ -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,6 +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
|
||||
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
|
||||
|
@ -464,6 +499,76 @@ class PostInteraction(StatorModel):
|
|||
interaction.post.calculate_stats()
|
||||
interaction.post.calculate_type_data()
|
||||
|
||||
@classmethod
|
||||
def handle_add_ap(cls, data):
|
||||
"""
|
||||
Handles an incoming Add activity which is a pin
|
||||
"""
|
||||
target = data.get("target", None)
|
||||
if not target:
|
||||
return
|
||||
|
||||
# we only care about pinned posts, not hashtags
|
||||
object = data.get("object", {})
|
||||
if isinstance(object, dict) and object.get("type") == "Hashtag":
|
||||
return
|
||||
|
||||
with transaction.atomic():
|
||||
identity = Identity.by_actor_uri(data["actor"], create=True)
|
||||
# it's only a pin if the target is the identity's featured collection URI
|
||||
if identity.featured_collection_uri != target:
|
||||
return
|
||||
|
||||
object_uri = get_str_or_id(object)
|
||||
if not object_uri:
|
||||
return
|
||||
post = Post.by_object_uri(object_uri, fetch=True)
|
||||
|
||||
return PostInteraction.objects.get_or_create(
|
||||
type=cls.Types.pin,
|
||||
identity=identity,
|
||||
post=post,
|
||||
state__in=PostInteractionStates.group_active(),
|
||||
)[0]
|
||||
|
||||
@classmethod
|
||||
def handle_remove_ap(cls, data):
|
||||
"""
|
||||
Handles an incoming Remove activity which is an unpin
|
||||
"""
|
||||
target = data.get("target", None)
|
||||
if not target:
|
||||
return
|
||||
|
||||
# we only care about pinned posts, not hashtags
|
||||
object = data.get("object", {})
|
||||
if isinstance(object, dict) and object.get("type") == "Hashtag":
|
||||
return
|
||||
|
||||
with transaction.atomic():
|
||||
identity = Identity.by_actor_uri(data["actor"], create=True)
|
||||
# it's only an unpin if the target is the identity's featured collection URI
|
||||
if identity.featured_collection_uri != target:
|
||||
return
|
||||
|
||||
try:
|
||||
object_uri = get_str_or_id(object)
|
||||
if not object_uri:
|
||||
return
|
||||
post = Post.by_object_uri(object_uri, fetch=False)
|
||||
for interaction in cls.objects.filter(
|
||||
type=cls.Types.pin,
|
||||
identity=identity,
|
||||
post=post,
|
||||
state__in=PostInteractionStates.group_active(),
|
||||
):
|
||||
# Force it into undone_fanned_out as it's not ours
|
||||
interaction.transition_perform(
|
||||
PostInteractionStates.undone_fanned_out
|
||||
)
|
||||
except (cls.DoesNotExist, Post.DoesNotExist):
|
||||
return
|
||||
|
||||
### Mastodon API ###
|
||||
|
||||
def to_mastodon_status_json(self, interactions=None, identity=None):
|
||||
|
|
|
@ -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)
|
||||
|
|
|
@ -108,6 +108,20 @@ class TimelineService:
|
|||
.order_by("-created")
|
||||
)
|
||||
|
||||
def identity_pinned(self) -> models.QuerySet[Post]:
|
||||
"""
|
||||
Return all pinned posts that are publicly visible for an identity
|
||||
"""
|
||||
return (
|
||||
PostService.queryset()
|
||||
.public()
|
||||
.filter(
|
||||
interactions__identity=self.identity,
|
||||
interactions__type=PostInteraction.Types.pin,
|
||||
interactions__state__in=PostInteractionStates.group_active(),
|
||||
)
|
||||
)
|
||||
|
||||
def likes(self) -> models.QuerySet[Post]:
|
||||
"""
|
||||
Return all liked posts for an identity
|
||||
|
|
|
@ -170,7 +170,9 @@ class Status(Schema):
|
|||
) -> "Status":
|
||||
return cls(
|
||||
**post.to_mastodon_json(
|
||||
interactions=interactions, bookmarks=bookmarks, identity=identity
|
||||
interactions=interactions,
|
||||
bookmarks=bookmarks,
|
||||
identity=identity,
|
||||
)
|
||||
)
|
||||
|
||||
|
@ -186,7 +188,10 @@ class Status(Schema):
|
|||
bookmarks = users_models.Bookmark.for_identity(identity, posts)
|
||||
return [
|
||||
cls.from_post(
|
||||
post, interactions=interactions, bookmarks=bookmarks, identity=identity
|
||||
post,
|
||||
interactions=interactions,
|
||||
bookmarks=bookmarks,
|
||||
identity=identity,
|
||||
)
|
||||
for post in posts
|
||||
]
|
||||
|
|
|
@ -95,6 +95,8 @@ urlpatterns = [
|
|||
path("v1/statuses/<id>/reblogged_by", statuses.reblogged_by),
|
||||
path("v1/statuses/<id>/bookmark", statuses.bookmark_status),
|
||||
path("v1/statuses/<id>/unbookmark", statuses.unbookmark_status),
|
||||
path("v1/statuses/<id>/pin", statuses.pin_status),
|
||||
path("v1/statuses/<id>/unpin", statuses.unpin_status),
|
||||
# Tags
|
||||
path("v1/followed_tags", tags.followed_tags),
|
||||
path("v1/tags/<hashtag>", tags.hashtag),
|
||||
|
|
|
@ -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:
|
||||
return ApiResponse([])
|
||||
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:
|
||||
|
|
|
@ -339,3 +339,28 @@ def unbookmark_status(request, id: str) -> schemas.Status:
|
|||
return schemas.Status.from_post(
|
||||
post, interactions=interactions, identity=request.identity
|
||||
)
|
||||
|
||||
|
||||
@scope_required("write:accounts")
|
||||
@api_view.post
|
||||
def pin_status(request, id: str) -> schemas.Status:
|
||||
post = post_for_id(request, id)
|
||||
try:
|
||||
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))
|
||||
|
||||
|
||||
@scope_required("write:accounts")
|
||||
@api_view.post
|
||||
def unpin_status(request, id: str) -> schemas.Status:
|
||||
post = post_for_id(request, id)
|
||||
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
|
||||
)
|
||||
|
|
|
@ -603,6 +603,7 @@ def canonicalise(json_data: dict, include_security: bool = False) -> dict:
|
|||
"sensitive": "as:sensitive",
|
||||
"toot": "http://joinmastodon.org/ns#",
|
||||
"votersCount": "toot:votersCount",
|
||||
"featured": {"@id": "toot:featured", "@type": "@id"},
|
||||
},
|
||||
]
|
||||
if include_security:
|
||||
|
|
|
@ -1671,7 +1671,8 @@ form .post {
|
|||
.boost-banner,
|
||||
.mention-banner,
|
||||
.follow-banner,
|
||||
.like-banner {
|
||||
.like-banner,
|
||||
.pinned-post-banner {
|
||||
padding: 0 0 3px 5px;
|
||||
}
|
||||
|
||||
|
@ -1720,6 +1721,12 @@ form .post {
|
|||
margin-right: 4px;
|
||||
}
|
||||
|
||||
.pinned-post-banner::before {
|
||||
content: "\f08d";
|
||||
font: var(--fa-font-solid);
|
||||
margin-right: 4px;
|
||||
}
|
||||
|
||||
.pagination {
|
||||
display: flex;
|
||||
justify-content: center;
|
||||
|
|
|
@ -240,6 +240,7 @@ urlpatterns = [
|
|||
path("@<handle>/", identity.ViewIdentity.as_view()),
|
||||
path("@<handle>/inbox/", activitypub.Inbox.as_view()),
|
||||
path("@<handle>/outbox/", activitypub.Outbox.as_view()),
|
||||
path("@<handle>/collections/featured/", activitypub.FeaturedCollection.as_view()),
|
||||
path("@<handle>/rss/", identity.IdentityFeed()),
|
||||
path("@<handle>/following/", identity.IdentityFollows.as_view(inbound=False)),
|
||||
path("@<handle>/followers/", identity.IdentityFollows.as_view(inbound=True)),
|
||||
|
|
|
@ -91,6 +91,12 @@
|
|||
{% block subcontent %}
|
||||
|
||||
<div class="page-content">
|
||||
{% for post in pinned_posts %}
|
||||
<div class="pinned-post-banner">
|
||||
Pinned post
|
||||
</div>
|
||||
{% include "activities/_post.html" %}
|
||||
{% endfor %}
|
||||
{% for event in page_obj %}
|
||||
{% if event.type == "post" %}
|
||||
{% include "activities/_post.html" with post=event.subject_post %}
|
||||
|
|
|
@ -3,7 +3,7 @@ from datetime import timedelta
|
|||
import pytest
|
||||
from django.utils import timezone
|
||||
|
||||
from activities.models import Post, PostInteraction
|
||||
from activities.models import Post, PostInteraction, PostInteractionStates
|
||||
from activities.models.post_types import QuestionData
|
||||
from core.ld import format_ld_date
|
||||
from users.models import Identity
|
||||
|
@ -312,3 +312,130 @@ def test_vote_to_ap(identity: Identity, remote_identity: Identity, config_system
|
|||
assert data["object"]["attributedTo"] == identity.actor_uri
|
||||
assert data["object"]["name"] == "Option 1"
|
||||
assert data["object"]["inReplyTo"] == post.object_uri
|
||||
|
||||
|
||||
@pytest.mark.django_db
|
||||
def test_handle_add_ap(remote_identity: Identity, config_system):
|
||||
post = Post.create_local(
|
||||
author=remote_identity,
|
||||
content="<p>Hello World</p>",
|
||||
)
|
||||
add_ap = {
|
||||
"type": "Add",
|
||||
"actor": "https://remote.test/test-actor/",
|
||||
"object": post.object_uri,
|
||||
"target": "https://remote.test/test-actor/collections/featured/",
|
||||
"@context": [
|
||||
"https://www.w3.org/ns/activitystreams",
|
||||
{
|
||||
"toot": "http://joinmastodon.org/ns#",
|
||||
"Emoji": "toot:Emoji",
|
||||
"Hashtag": "as:Hashtag",
|
||||
"blurhash": "toot:blurhash",
|
||||
"featured": {"@id": "toot:featured", "@type": "@id"},
|
||||
"sensitive": "as:sensitive",
|
||||
"focalPoint": {"@id": "toot:focalPoint", "@container": "@list"},
|
||||
"votersCount": "toot:votersCount",
|
||||
"manuallyApprovesFollowers": "as:manuallyApprovesFollowers",
|
||||
},
|
||||
"https://w3id.org/security/v1",
|
||||
],
|
||||
}
|
||||
|
||||
# mismatched target with identity's featured_collection_uri is a no-op
|
||||
PostInteraction.handle_add_ap(data=add_ap | {"target": "different-target"})
|
||||
assert (
|
||||
PostInteraction.objects.filter(
|
||||
type=PostInteraction.Types.pin, post=post
|
||||
).count()
|
||||
== 0
|
||||
)
|
||||
|
||||
# successfully add a pin interaction
|
||||
PostInteraction.handle_add_ap(
|
||||
data=add_ap,
|
||||
)
|
||||
assert (
|
||||
PostInteraction.objects.filter(
|
||||
type=PostInteraction.Types.pin, post=post
|
||||
).count()
|
||||
== 1
|
||||
)
|
||||
|
||||
# second identical Add activity is a no-op
|
||||
PostInteraction.handle_add_ap(
|
||||
data=add_ap,
|
||||
)
|
||||
assert (
|
||||
PostInteraction.objects.filter(
|
||||
type=PostInteraction.Types.pin, post=post
|
||||
).count()
|
||||
== 1
|
||||
)
|
||||
|
||||
# new Add activity for inactive interaction creates a new one
|
||||
old_interaction = PostInteraction.objects.get(
|
||||
type=PostInteraction.Types.pin, post=post
|
||||
)
|
||||
old_interaction.transition_perform(PostInteractionStates.undone_fanned_out)
|
||||
PostInteraction.handle_add_ap(
|
||||
data=add_ap,
|
||||
)
|
||||
new_interaction = PostInteraction.objects.get(
|
||||
type=PostInteraction.Types.pin,
|
||||
post=post,
|
||||
state__in=PostInteractionStates.group_active(),
|
||||
)
|
||||
assert new_interaction.pk != old_interaction.pk
|
||||
|
||||
|
||||
@pytest.mark.django_db
|
||||
def test_handle_remove_ap(remote_identity: Identity, config_system):
|
||||
post = Post.create_local(
|
||||
author=remote_identity,
|
||||
content="<p>Hello World</p>",
|
||||
)
|
||||
interaction = PostInteraction.objects.create(
|
||||
type=PostInteraction.Types.pin,
|
||||
identity=remote_identity,
|
||||
post=post,
|
||||
)
|
||||
remove_ap = {
|
||||
"type": "Remove",
|
||||
"actor": "https://remote.test/test-actor/",
|
||||
"object": post.object_uri,
|
||||
"target": "https://remote.test/test-actor/collections/featured/",
|
||||
"@context": [
|
||||
"https://www.w3.org/ns/activitystreams",
|
||||
{
|
||||
"toot": "http://joinmastodon.org/ns#",
|
||||
"Emoji": "toot:Emoji",
|
||||
"Hashtag": "as:Hashtag",
|
||||
"blurhash": "toot:blurhash",
|
||||
"featured": {"@id": "toot:featured", "@type": "@id"},
|
||||
"sensitive": "as:sensitive",
|
||||
"focalPoint": {"@id": "toot:focalPoint", "@container": "@list"},
|
||||
"votersCount": "toot:votersCount",
|
||||
"manuallyApprovesFollowers": "as:manuallyApprovesFollowers",
|
||||
},
|
||||
"https://w3id.org/security/v1",
|
||||
],
|
||||
}
|
||||
|
||||
interaction.refresh_from_db()
|
||||
|
||||
# mismatched target with identity's featured_collection_uri is a no-op
|
||||
initial_state = interaction.state
|
||||
PostInteraction.handle_remove_ap(data=remove_ap | {"target": "different-target"})
|
||||
interaction.refresh_from_db()
|
||||
assert initial_state == interaction.state
|
||||
|
||||
# successfully remove a pin interaction
|
||||
PostInteraction.handle_remove_ap(
|
||||
data=remove_ap,
|
||||
)
|
||||
interaction.refresh_from_db()
|
||||
assert interaction.state == PostInteractionStates.undone_fanned_out
|
||||
|
||||
# Remove activity on unknown post is a no-op
|
||||
PostInteraction.handle_remove_ap(data=remove_ap | {"object": "unknown-post"})
|
||||
|
|
|
@ -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
|
||||
)
|
||||
|
|
|
@ -177,6 +177,7 @@ def remote_identity() -> Identity:
|
|||
actor_uri="https://remote.test/test-actor/",
|
||||
inbox_uri="https://remote.test/@test/inbox/",
|
||||
profile_uri="https://remote.test/@test/",
|
||||
featured_collection_uri="https://remote.test/test-actor/collections/featured/",
|
||||
username="test",
|
||||
domain=domain,
|
||||
name="Test Remote User",
|
||||
|
|
|
@ -135,6 +135,10 @@ def test_fetch_actor(httpx_mock, config_system):
|
|||
"@context": [
|
||||
"https://www.w3.org/ns/activitystreams",
|
||||
"https://w3id.org/security/v1",
|
||||
{
|
||||
"toot": "http://joinmastodon.org/ns#",
|
||||
"featured": {"@id": "toot:featured", "@type": "@id"},
|
||||
},
|
||||
],
|
||||
"id": "https://example.com/test-actor/",
|
||||
"type": "Person",
|
||||
|
@ -146,6 +150,7 @@ def test_fetch_actor(httpx_mock, config_system):
|
|||
},
|
||||
"followers": "https://example.com/test-actor/followers/",
|
||||
"following": "https://example.com/test-actor/following/",
|
||||
"featured": "https://example.com/test-actor/collections/featured/",
|
||||
"icon": {
|
||||
"type": "Image",
|
||||
"mediaType": "image/jpeg",
|
||||
|
@ -173,6 +178,10 @@ def test_fetch_actor(httpx_mock, config_system):
|
|||
assert identity.domain_id == "example.com"
|
||||
assert identity.profile_uri == "https://example.com/test-actor/view/"
|
||||
assert identity.inbox_uri == "https://example.com/test-actor/inbox/"
|
||||
assert (
|
||||
identity.featured_collection_uri
|
||||
== "https://example.com/test-actor/collections/featured/"
|
||||
)
|
||||
assert identity.icon_uri == "https://example.com/icon.jpg"
|
||||
assert identity.image_uri == "https://example.com/image.jpg"
|
||||
assert identity.summary == "<p>A test user</p>"
|
||||
|
|
18
users/migrations/0017_identity_featured_collection_uri.py
Normal file
18
users/migrations/0017_identity_featured_collection_uri.py
Normal file
|
@ -0,0 +1,18 @@
|
|||
# Generated by Django 4.1.7 on 2023-04-23 20:04
|
||||
|
||||
from django.db import migrations, models
|
||||
|
||||
|
||||
class Migration(migrations.Migration):
|
||||
|
||||
dependencies = [
|
||||
("users", "0016_hashtagfollow"),
|
||||
]
|
||||
|
||||
operations = [
|
||||
migrations.AddField(
|
||||
model_name="identity",
|
||||
name="featured_collection_uri",
|
||||
field=models.CharField(blank=True, max_length=500, null=True),
|
||||
),
|
||||
]
|
|
@ -188,6 +188,7 @@ class Identity(StatorModel):
|
|||
image_uri = models.CharField(max_length=500, blank=True, null=True)
|
||||
followers_uri = models.CharField(max_length=500, blank=True, null=True)
|
||||
following_uri = models.CharField(max_length=500, blank=True, null=True)
|
||||
featured_collection_uri = models.CharField(max_length=500, blank=True, null=True)
|
||||
actor_type = models.CharField(max_length=100, default="person")
|
||||
|
||||
icon = models.ImageField(
|
||||
|
@ -498,6 +499,7 @@ class Identity(StatorModel):
|
|||
"type": self.actor_type.title(),
|
||||
"inbox": self.actor_uri + "inbox/",
|
||||
"outbox": self.actor_uri + "outbox/",
|
||||
"featured": self.actor_uri + "collections/featured/",
|
||||
"preferredUsername": self.username,
|
||||
"publicKey": {
|
||||
"id": self.public_key_id,
|
||||
|
@ -726,12 +728,58 @@ class Identity(StatorModel):
|
|||
pass
|
||||
return None, None
|
||||
|
||||
@classmethod
|
||||
async def fetch_pinned_post_uris(cls, uri: str) -> list[str]:
|
||||
"""
|
||||
Fetch an identity's featured collection.
|
||||
"""
|
||||
async with httpx.AsyncClient(
|
||||
timeout=settings.SETUP.REMOTE_TIMEOUT,
|
||||
headers={"User-Agent": settings.TAKAHE_USER_AGENT},
|
||||
) as client:
|
||||
try:
|
||||
response = await client.get(
|
||||
uri,
|
||||
follow_redirects=True,
|
||||
headers={"Accept": "application/activity+json"},
|
||||
)
|
||||
response.raise_for_status()
|
||||
except (httpx.HTTPError, ssl.SSLCertVerificationError) as ex:
|
||||
response = getattr(ex, "response", None)
|
||||
if (
|
||||
response
|
||||
and response.status_code < 500
|
||||
and response.status_code not in [401, 403, 404, 406, 410]
|
||||
):
|
||||
raise ValueError(
|
||||
f"Client error fetching featured collection: {response.status_code}",
|
||||
response.content,
|
||||
)
|
||||
return []
|
||||
|
||||
try:
|
||||
data = canonicalise(response.json(), include_security=True)
|
||||
if "orderedItems" in data:
|
||||
return [item["id"] for item in reversed(data["orderedItems"])]
|
||||
elif "items" in data:
|
||||
return [item["id"] for item in data["items"]]
|
||||
return []
|
||||
except ValueError:
|
||||
# Some servers return these with a 200 status code!
|
||||
if b"not found" in response.content.lower():
|
||||
return []
|
||||
raise ValueError(
|
||||
"JSON parse error fetching featured collection",
|
||||
response.content,
|
||||
)
|
||||
|
||||
async def fetch_actor(self) -> bool:
|
||||
"""
|
||||
Fetches the user's actor information, as well as their domain from
|
||||
webfinger if it's available.
|
||||
"""
|
||||
from activities.models import Emoji
|
||||
from users.services import IdentityService
|
||||
|
||||
if self.local:
|
||||
raise ValueError("Cannot fetch local identities")
|
||||
|
@ -772,6 +820,7 @@ class Identity(StatorModel):
|
|||
self.outbox_uri = document.get("outbox")
|
||||
self.followers_uri = document.get("followers")
|
||||
self.following_uri = document.get("following")
|
||||
self.featured_collection_uri = document.get("featured")
|
||||
self.actor_type = document["type"].lower()
|
||||
self.shared_inbox_uri = document.get("endpoints", {}).get("sharedInbox")
|
||||
self.summary = document.get("summary")
|
||||
|
@ -839,6 +888,13 @@ class Identity(StatorModel):
|
|||
)
|
||||
self.pk: int | None = other_row.pk
|
||||
await sync_to_async(self.save)()
|
||||
|
||||
# Fetch pinned posts after identity has been fetched and saved
|
||||
if self.featured_collection_uri:
|
||||
featured = await self.fetch_pinned_post_uris(self.featured_collection_uri)
|
||||
service = IdentityService(self)
|
||||
await sync_to_async(service.sync_pins)(featured)
|
||||
|
||||
return True
|
||||
|
||||
### OpenGraph API ###
|
||||
|
|
|
@ -129,11 +129,9 @@ class InboxMessageStates(StateGraph):
|
|||
f"Cannot handle activity of type delete.{unknown}"
|
||||
)
|
||||
case "add":
|
||||
# We are ignoring these right now (probably pinned items)
|
||||
pass
|
||||
await sync_to_async(PostInteraction.handle_add_ap)(instance.message)
|
||||
case "remove":
|
||||
# We are ignoring these right now (probably pinned items)
|
||||
pass
|
||||
await sync_to_async(PostInteraction.handle_remove_ap)(instance.message)
|
||||
case "move":
|
||||
# We're ignoring moves for now
|
||||
pass
|
||||
|
|
|
@ -1,7 +1,7 @@
|
|||
from django.db import models
|
||||
from django.db import models, transaction
|
||||
from django.template.defaultfilters import linebreaks_filter
|
||||
|
||||
from activities.models import FanOut
|
||||
from activities.models import FanOut, Post, PostInteraction, PostInteractionStates
|
||||
from core.files import resize_image
|
||||
from core.html import FediverseHtmlParser
|
||||
from users.models import (
|
||||
|
@ -184,6 +184,26 @@ class IdentityService:
|
|||
),
|
||||
}
|
||||
|
||||
def sync_pins(self, object_uris):
|
||||
if not object_uris:
|
||||
return
|
||||
|
||||
with transaction.atomic():
|
||||
for object_uri in object_uris:
|
||||
post = Post.by_object_uri(object_uri, fetch=True)
|
||||
PostInteraction.objects.get_or_create(
|
||||
type=PostInteraction.Types.pin,
|
||||
identity=self.identity,
|
||||
post=post,
|
||||
state__in=PostInteractionStates.group_active(),
|
||||
)
|
||||
for removed in PostInteraction.objects.filter(
|
||||
type=PostInteraction.Types.pin,
|
||||
identity=self.identity,
|
||||
state__in=PostInteractionStates.group_active(),
|
||||
).exclude(post__object_uri__in=object_uris):
|
||||
removed.transition_perform(PostInteractionStates.undone_fanned_out)
|
||||
|
||||
def mastodon_json_relationship(self, from_identity: Identity):
|
||||
"""
|
||||
Returns a Relationship object for the from_identity's relationship
|
||||
|
|
|
@ -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
|
||||
|
@ -222,6 +223,34 @@ class Outbox(View):
|
|||
)
|
||||
|
||||
|
||||
class FeaturedCollection(View):
|
||||
"""
|
||||
An ordered collection of all pinned posts of an identity
|
||||
"""
|
||||
|
||||
def get(self, request, handle):
|
||||
self.identity = by_handle_or_404(
|
||||
request,
|
||||
handle,
|
||||
local=False,
|
||||
fetch=True,
|
||||
)
|
||||
if not self.identity.local:
|
||||
raise Http404("Not a local identity")
|
||||
posts = list(TimelineService(self.identity).identity_pinned())
|
||||
return JsonResponse(
|
||||
canonicalise(
|
||||
{
|
||||
"type": "OrderedCollection",
|
||||
"id": self.identity.actor_uri + "collections/featured/",
|
||||
"totalItems": len(posts),
|
||||
"orderedItems": [post.to_ap() for post in posts],
|
||||
}
|
||||
),
|
||||
content_type="application/activity+json",
|
||||
)
|
||||
|
||||
|
||||
@method_decorator(cache_control(max_age=60 * 15), name="dispatch")
|
||||
class EmptyOutbox(StaticContentView):
|
||||
"""
|
||||
|
|
|
@ -71,6 +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.identity).identity_pinned()
|
||||
if self.identity.config_identity.visible_follows:
|
||||
context["followers_count"] = self.identity.inbound_follows.filter(
|
||||
state__in=FollowStates.group_active()
|
||||
|
|
Loading…
Reference in a new issue