From d6c9ba08199bc15d23f7e19e8047fd8e3ab9240d Mon Sep 17 00:00:00 2001 From: Christof Dorner Date: Sat, 13 May 2023 16:01:27 +0000 Subject: [PATCH] Pinned posts (#561) --- .../0015_alter_postinteraction_type.py | 26 ++++ activities/models/fan_out.py | 22 +-- activities/models/post.py | 1 + activities/models/post_interaction.py | 125 +++++++++++++++-- activities/services/post.py | 19 +++ activities/services/timeline.py | 14 ++ api/schemas.py | 9 +- api/urls.py | 2 + api/views/accounts.py | 7 +- api/views/statuses.py | 25 ++++ core/ld.py | 1 + static/css/style.css | 9 +- takahe/urls.py | 1 + templates/identity/view.html | 6 + .../models/test_post_interaction.py | 129 +++++++++++++++++- tests/activities/services/test_post.py | 77 ++++++++++- tests/conftest.py | 1 + tests/users/models/test_identity.py | 9 ++ .../0017_identity_featured_collection_uri.py | 18 +++ users/models/identity.py | 56 ++++++++ users/models/inbox_message.py | 6 +- users/services/identity.py | 24 +++- users/views/activitypub.py | 29 ++++ users/views/identity.py | 1 + 24 files changed, 586 insertions(+), 31 deletions(-) create mode 100644 activities/migrations/0015_alter_postinteraction_type.py create mode 100644 users/migrations/0017_identity_featured_collection_uri.py diff --git a/activities/migrations/0015_alter_postinteraction_type.py b/activities/migrations/0015_alter_postinteraction_type.py new file mode 100644 index 0000000..bb1a114 --- /dev/null +++ b/activities/migrations/0015_alter_postinteraction_type.py @@ -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, + ), + ), + ] diff --git a/activities/models/fan_out.py b/activities/models/fan_out.py index ff6fc64..9bdc2fd 100644 --- a/activities/models/fan_out.py +++ b/activities/models/fan_out.py @@ -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 diff --git a/activities/models/post.py b/activities/models/post.py index 6f2b184..88fba62 100644 --- a/activities/models/post.py +++ b/activities/models/post.py @@ -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 diff --git a/activities/models/post_interaction.py b/activities/models/post_interaction.py index be94062..eb8d684 100644 --- a/activities/models/post_interaction.py +++ b/activities/models/post_interaction.py @@ -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): diff --git a/activities/services/post.py b/activities/services/post.py index c5adb32..53be357 100644 --- a/activities/services/post.py +++ b/activities/services/post.py @@ -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) diff --git a/activities/services/timeline.py b/activities/services/timeline.py index 5e7015d..a67a25e 100644 --- a/activities/services/timeline.py +++ b/activities/services/timeline.py @@ -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 diff --git a/api/schemas.py b/api/schemas.py index de0ce0a..30ca293 100644 --- a/api/schemas.py +++ b/api/schemas.py @@ -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 ] diff --git a/api/urls.py b/api/urls.py index 351a9c4..e3e541a 100644 --- a/api/urls.py +++ b/api/urls.py @@ -95,6 +95,8 @@ urlpatterns = [ path("v1/statuses//reblogged_by", statuses.reblogged_by), path("v1/statuses//bookmark", statuses.bookmark_status), path("v1/statuses//unbookmark", statuses.unbookmark_status), + path("v1/statuses//pin", statuses.pin_status), + path("v1/statuses//unpin", statuses.unpin_status), # Tags path("v1/followed_tags", tags.followed_tags), path("v1/tags/", tags.hashtag), diff --git a/api/views/accounts.py b/api/views/accounts.py index 3c938b4..14729c1 100644 --- a/api/views/accounts.py +++ b/api/views/accounts.py @@ -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: diff --git a/api/views/statuses.py b/api/views/statuses.py index fba1094..4e20993 100644 --- a/api/views/statuses.py +++ b/api/views/statuses.py @@ -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 + ) diff --git a/core/ld.py b/core/ld.py index 184a7b5..8bbc088 100644 --- a/core/ld.py +++ b/core/ld.py @@ -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: diff --git a/static/css/style.css b/static/css/style.css index 72edc3c..1c5c321 100644 --- a/static/css/style.css +++ b/static/css/style.css @@ -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; diff --git a/takahe/urls.py b/takahe/urls.py index 6ee5444..63239fc 100644 --- a/takahe/urls.py +++ b/takahe/urls.py @@ -240,6 +240,7 @@ urlpatterns = [ path("@/", identity.ViewIdentity.as_view()), path("@/inbox/", activitypub.Inbox.as_view()), path("@/outbox/", activitypub.Outbox.as_view()), + path("@/collections/featured/", activitypub.FeaturedCollection.as_view()), path("@/rss/", identity.IdentityFeed()), path("@/following/", identity.IdentityFollows.as_view(inbound=False)), path("@/followers/", identity.IdentityFollows.as_view(inbound=True)), diff --git a/templates/identity/view.html b/templates/identity/view.html index 16e31d0..0bfa3b3 100644 --- a/templates/identity/view.html +++ b/templates/identity/view.html @@ -91,6 +91,12 @@ {% block subcontent %}
+ {% for post in pinned_posts %} +
+ Pinned post +
+ {% include "activities/_post.html" %} + {% endfor %} {% for event in page_obj %} {% if event.type == "post" %} {% include "activities/_post.html" with post=event.subject_post %} diff --git a/tests/activities/models/test_post_interaction.py b/tests/activities/models/test_post_interaction.py index ced7b4f..0bdb412 100644 --- a/tests/activities/models/test_post_interaction.py +++ b/tests/activities/models/test_post_interaction.py @@ -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="

Hello World

", + ) + 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="

Hello World

", + ) + 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"}) diff --git a/tests/activities/services/test_post.py b/tests/activities/services/test_post.py index 4453fc8..9fcb56f 100644 --- a/tests/activities/services/test_post.py +++ b/tests/activities/services/test_post.py @@ -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 + ) diff --git a/tests/conftest.py b/tests/conftest.py index d96c79e..975e62a 100644 --- a/tests/conftest.py +++ b/tests/conftest.py @@ -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", diff --git a/tests/users/models/test_identity.py b/tests/users/models/test_identity.py index 27cd8c4..4bbea1a 100644 --- a/tests/users/models/test_identity.py +++ b/tests/users/models/test_identity.py @@ -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 == "

A test user

" diff --git a/users/migrations/0017_identity_featured_collection_uri.py b/users/migrations/0017_identity_featured_collection_uri.py new file mode 100644 index 0000000..cd9d43c --- /dev/null +++ b/users/migrations/0017_identity_featured_collection_uri.py @@ -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), + ), + ] diff --git a/users/models/identity.py b/users/models/identity.py index f8daad2..2e4c351 100644 --- a/users/models/identity.py +++ b/users/models/identity.py @@ -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 ### diff --git a/users/models/inbox_message.py b/users/models/inbox_message.py index aa01e0a..5d057f9 100644 --- a/users/models/inbox_message.py +++ b/users/models/inbox_message.py @@ -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 diff --git a/users/services/identity.py b/users/services/identity.py index 4522431..3ee0195 100644 --- a/users/services/identity.py +++ b/users/services/identity.py @@ -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 diff --git a/users/views/activitypub.py b/users/views/activitypub.py index d788c34..192fd80 100644 --- a/users/views/activitypub.py +++ b/users/views/activitypub.py @@ -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): """ diff --git a/users/views/identity.py b/users/views/identity.py index 7de4064..5fad68c 100644 --- a/users/views/identity.py +++ b/users/views/identity.py @@ -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()