Pinned posts (#561)

This commit is contained in:
Christof Dorner 2023-05-13 16:01:27 +00:00 committed by GitHub
parent 744c2825d9
commit d6c9ba0819
No known key found for this signature in database
GPG key ID: 4AEE18F83AFDEB23
24 changed files with 586 additions and 31 deletions

View 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,
),
),
]

View file

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

View file

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

View file

@ -34,8 +34,12 @@ class PostInteractionStates(StateGraph):
interaction = await instance.afetch_full() interaction = await instance.afetch_full()
# Boost: send a copy to all people who follow this user (limiting # Boost: send a copy to all people who follow this user (limiting
# to just local follows if it's a remote boost) # to just local follows if it's a remote boost)
if interaction.type == interaction.Types.boost: # Pin: send Add activity to all people who follow this user
for target in await interaction.aget_boost_targets(): if (
interaction.type == interaction.Types.boost
or interaction.type == interaction.Types.pin
):
for target in await interaction.aget_targets():
await FanOut.objects.acreate( await FanOut.objects.acreate(
type=FanOut.Types.interaction, type=FanOut.Types.interaction,
identity=target, identity=target,
@ -85,7 +89,11 @@ class PostInteractionStates(StateGraph):
""" """
interaction = await instance.afetch_full() interaction = await instance.afetch_full()
# Undo Boost: send a copy to all people who follow this user # 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( async for follow in interaction.identity.inbound_follows.select_related(
"source", "target" "source", "target"
): ):
@ -129,6 +137,7 @@ class PostInteraction(StatorModel):
like = "like" like = "like"
boost = "boost" boost = "boost"
vote = "vote" vote = "vote"
pin = "pin"
id = models.BigIntegerField( id = models.BigIntegerField(
primary_key=True, primary_key=True,
@ -186,7 +195,7 @@ class PostInteraction(StatorModel):
ids_with_interaction_type = cls.objects.filter( ids_with_interaction_type = cls.objects.filter(
identity=identity, identity=identity,
post_id__in=[post.pk for post in posts], 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], state__in=[PostInteractionStates.new, PostInteractionStates.fanned_out],
).values_list("post_id", "type") ).values_list("post_id", "type")
# Make it into the return dict # Make it into the return dict
@ -215,18 +224,22 @@ class PostInteraction(StatorModel):
"identity", "post", "post__author" "identity", "post", "post__author"
).aget(pk=self.pk) ).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 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 # Start including the post author
targets = {self.post.author} targets = {self.post.author}
query = self.identity.inbound_follows.active()
# Include all followers that are following the boosts # Include all followers that are following the boosts
async for follow in self.identity.inbound_follows.active().filter( if self.type == self.Types.boost:
boosts=True query = query.filter(boosts=True)
).select_related("source"): async for follow in query.select_related("source"):
targets.add(follow.source) targets.add(follow.source)
# Fetch the full blocks and remove them as targets # Fetch the full blocks and remove them as targets
@ -326,7 +339,7 @@ class PostInteraction(StatorModel):
"inReplyTo": self.post.object_uri, "inReplyTo": self.post.object_uri,
"attributedTo": self.identity.actor_uri, "attributedTo": self.identity.actor_uri,
} }
else: elif self.type == self.Types.pin:
raise ValueError("Cannot turn into AP") raise ValueError("Cannot turn into AP")
return value return value
@ -356,6 +369,28 @@ class PostInteraction(StatorModel):
"object": object, "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) ### ### ActivityPub (inbound) ###
@classmethod @classmethod
@ -464,6 +499,76 @@ class PostInteraction(StatorModel):
interaction.post.calculate_stats() interaction.post.calculate_stats()
interaction.post.calculate_type_data() 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 ### ### Mastodon API ###
def to_mastodon_status_json(self, interactions=None, identity=None): def to_mastodon_status_json(self, interactions=None, identity=None):

View file

@ -142,3 +142,22 @@ class PostService:
), ),
PostInteractionStates.undone, 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)

View file

@ -108,6 +108,20 @@ class TimelineService:
.order_by("-created") .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]: def likes(self) -> models.QuerySet[Post]:
""" """
Return all liked posts for an identity Return all liked posts for an identity

View file

@ -170,7 +170,9 @@ class Status(Schema):
) -> "Status": ) -> "Status":
return cls( return cls(
**post.to_mastodon_json( **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) bookmarks = users_models.Bookmark.for_identity(identity, posts)
return [ return [
cls.from_post( cls.from_post(
post, interactions=interactions, bookmarks=bookmarks, identity=identity post,
interactions=interactions,
bookmarks=bookmarks,
identity=identity,
) )
for post in posts for post in posts
] ]

View file

@ -95,6 +95,8 @@ urlpatterns = [
path("v1/statuses/<id>/reblogged_by", statuses.reblogged_by), path("v1/statuses/<id>/reblogged_by", statuses.reblogged_by),
path("v1/statuses/<id>/bookmark", statuses.bookmark_status), path("v1/statuses/<id>/bookmark", statuses.bookmark_status),
path("v1/statuses/<id>/unbookmark", statuses.unbookmark_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 # Tags
path("v1/followed_tags", tags.followed_tags), path("v1/followed_tags", tags.followed_tags),
path("v1/tags/<hashtag>", tags.hashtag), path("v1/tags/<hashtag>", tags.hashtag),

View file

@ -5,7 +5,7 @@ from django.http import HttpRequest
from django.shortcuts import get_object_or_404 from django.shortcuts import get_object_or_404
from hatchway import ApiResponse, QueryOrBody, api_view 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 activities.services import SearchService
from api import schemas from api import schemas
from api.decorators import scope_required from api.decorators import scope_required
@ -200,7 +200,10 @@ def account_statuses(
.order_by("-created") .order_by("-created")
) )
if pinned: if pinned:
return ApiResponse([]) queryset = queryset.filter(
interactions__type=PostInteraction.Types.pin,
interactions__state__in=PostInteractionStates.group_active(),
)
if only_media: if only_media:
queryset = queryset.filter(attachments__pk__isnull=False) queryset = queryset.filter(attachments__pk__isnull=False)
if tagged: if tagged:

View file

@ -339,3 +339,28 @@ def unbookmark_status(request, id: str) -> schemas.Status:
return schemas.Status.from_post( return schemas.Status.from_post(
post, interactions=interactions, identity=request.identity 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
)

View file

@ -603,6 +603,7 @@ def canonicalise(json_data: dict, include_security: bool = False) -> dict:
"sensitive": "as:sensitive", "sensitive": "as:sensitive",
"toot": "http://joinmastodon.org/ns#", "toot": "http://joinmastodon.org/ns#",
"votersCount": "toot:votersCount", "votersCount": "toot:votersCount",
"featured": {"@id": "toot:featured", "@type": "@id"},
}, },
] ]
if include_security: if include_security:

View file

@ -1671,7 +1671,8 @@ form .post {
.boost-banner, .boost-banner,
.mention-banner, .mention-banner,
.follow-banner, .follow-banner,
.like-banner { .like-banner,
.pinned-post-banner {
padding: 0 0 3px 5px; padding: 0 0 3px 5px;
} }
@ -1720,6 +1721,12 @@ form .post {
margin-right: 4px; margin-right: 4px;
} }
.pinned-post-banner::before {
content: "\f08d";
font: var(--fa-font-solid);
margin-right: 4px;
}
.pagination { .pagination {
display: flex; display: flex;
justify-content: center; justify-content: center;

View file

@ -240,6 +240,7 @@ urlpatterns = [
path("@<handle>/", identity.ViewIdentity.as_view()), path("@<handle>/", identity.ViewIdentity.as_view()),
path("@<handle>/inbox/", activitypub.Inbox.as_view()), path("@<handle>/inbox/", activitypub.Inbox.as_view()),
path("@<handle>/outbox/", activitypub.Outbox.as_view()), path("@<handle>/outbox/", activitypub.Outbox.as_view()),
path("@<handle>/collections/featured/", activitypub.FeaturedCollection.as_view()),
path("@<handle>/rss/", identity.IdentityFeed()), path("@<handle>/rss/", identity.IdentityFeed()),
path("@<handle>/following/", identity.IdentityFollows.as_view(inbound=False)), path("@<handle>/following/", identity.IdentityFollows.as_view(inbound=False)),
path("@<handle>/followers/", identity.IdentityFollows.as_view(inbound=True)), path("@<handle>/followers/", identity.IdentityFollows.as_view(inbound=True)),

View file

@ -91,6 +91,12 @@
{% block subcontent %} {% block subcontent %}
<div class="page-content"> <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 %} {% for event in page_obj %}
{% if event.type == "post" %} {% if event.type == "post" %}
{% include "activities/_post.html" with post=event.subject_post %} {% include "activities/_post.html" with post=event.subject_post %}

View file

@ -3,7 +3,7 @@ from datetime import timedelta
import pytest import pytest
from django.utils import timezone 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 activities.models.post_types import QuestionData
from core.ld import format_ld_date from core.ld import format_ld_date
from users.models import Identity 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"]["attributedTo"] == identity.actor_uri
assert data["object"]["name"] == "Option 1" assert data["object"]["name"] == "Option 1"
assert data["object"]["inReplyTo"] == post.object_uri 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"})

View file

@ -1,6 +1,6 @@
import pytest import pytest
from activities.models import Post from activities.models import Post, PostInteraction
from activities.services import PostService from activities.services import PostService
from users.models import Identity from users.models import Identity
@ -35,3 +35,78 @@ def test_post_context(identity: Identity, config_system):
ancestors, descendants = PostService(post3).context(None) ancestors, descendants = PostService(post3).context(None)
assert ancestors == [post2, post1] assert ancestors == [post2, post1]
assert descendants == [] 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
)

View file

@ -177,6 +177,7 @@ def remote_identity() -> Identity:
actor_uri="https://remote.test/test-actor/", actor_uri="https://remote.test/test-actor/",
inbox_uri="https://remote.test/@test/inbox/", inbox_uri="https://remote.test/@test/inbox/",
profile_uri="https://remote.test/@test/", profile_uri="https://remote.test/@test/",
featured_collection_uri="https://remote.test/test-actor/collections/featured/",
username="test", username="test",
domain=domain, domain=domain,
name="Test Remote User", name="Test Remote User",

View file

@ -135,6 +135,10 @@ def test_fetch_actor(httpx_mock, config_system):
"@context": [ "@context": [
"https://www.w3.org/ns/activitystreams", "https://www.w3.org/ns/activitystreams",
"https://w3id.org/security/v1", "https://w3id.org/security/v1",
{
"toot": "http://joinmastodon.org/ns#",
"featured": {"@id": "toot:featured", "@type": "@id"},
},
], ],
"id": "https://example.com/test-actor/", "id": "https://example.com/test-actor/",
"type": "Person", "type": "Person",
@ -146,6 +150,7 @@ def test_fetch_actor(httpx_mock, config_system):
}, },
"followers": "https://example.com/test-actor/followers/", "followers": "https://example.com/test-actor/followers/",
"following": "https://example.com/test-actor/following/", "following": "https://example.com/test-actor/following/",
"featured": "https://example.com/test-actor/collections/featured/",
"icon": { "icon": {
"type": "Image", "type": "Image",
"mediaType": "image/jpeg", "mediaType": "image/jpeg",
@ -173,6 +178,10 @@ def test_fetch_actor(httpx_mock, config_system):
assert identity.domain_id == "example.com" assert identity.domain_id == "example.com"
assert identity.profile_uri == "https://example.com/test-actor/view/" assert identity.profile_uri == "https://example.com/test-actor/view/"
assert identity.inbox_uri == "https://example.com/test-actor/inbox/" 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.icon_uri == "https://example.com/icon.jpg"
assert identity.image_uri == "https://example.com/image.jpg" assert identity.image_uri == "https://example.com/image.jpg"
assert identity.summary == "<p>A test user</p>" assert identity.summary == "<p>A test user</p>"

View 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),
),
]

View file

@ -188,6 +188,7 @@ class Identity(StatorModel):
image_uri = models.CharField(max_length=500, blank=True, null=True) image_uri = models.CharField(max_length=500, blank=True, null=True)
followers_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) 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") actor_type = models.CharField(max_length=100, default="person")
icon = models.ImageField( icon = models.ImageField(
@ -498,6 +499,7 @@ class Identity(StatorModel):
"type": self.actor_type.title(), "type": self.actor_type.title(),
"inbox": self.actor_uri + "inbox/", "inbox": self.actor_uri + "inbox/",
"outbox": self.actor_uri + "outbox/", "outbox": self.actor_uri + "outbox/",
"featured": self.actor_uri + "collections/featured/",
"preferredUsername": self.username, "preferredUsername": self.username,
"publicKey": { "publicKey": {
"id": self.public_key_id, "id": self.public_key_id,
@ -726,12 +728,58 @@ class Identity(StatorModel):
pass pass
return None, None 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: async def fetch_actor(self) -> bool:
""" """
Fetches the user's actor information, as well as their domain from Fetches the user's actor information, as well as their domain from
webfinger if it's available. webfinger if it's available.
""" """
from activities.models import Emoji from activities.models import Emoji
from users.services import IdentityService
if self.local: if self.local:
raise ValueError("Cannot fetch local identities") raise ValueError("Cannot fetch local identities")
@ -772,6 +820,7 @@ class Identity(StatorModel):
self.outbox_uri = document.get("outbox") self.outbox_uri = document.get("outbox")
self.followers_uri = document.get("followers") self.followers_uri = document.get("followers")
self.following_uri = document.get("following") self.following_uri = document.get("following")
self.featured_collection_uri = document.get("featured")
self.actor_type = document["type"].lower() self.actor_type = document["type"].lower()
self.shared_inbox_uri = document.get("endpoints", {}).get("sharedInbox") self.shared_inbox_uri = document.get("endpoints", {}).get("sharedInbox")
self.summary = document.get("summary") self.summary = document.get("summary")
@ -839,6 +888,13 @@ class Identity(StatorModel):
) )
self.pk: int | None = other_row.pk self.pk: int | None = other_row.pk
await sync_to_async(self.save)() 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 return True
### OpenGraph API ### ### OpenGraph API ###

View file

@ -129,11 +129,9 @@ class InboxMessageStates(StateGraph):
f"Cannot handle activity of type delete.{unknown}" f"Cannot handle activity of type delete.{unknown}"
) )
case "add": case "add":
# We are ignoring these right now (probably pinned items) await sync_to_async(PostInteraction.handle_add_ap)(instance.message)
pass
case "remove": case "remove":
# We are ignoring these right now (probably pinned items) await sync_to_async(PostInteraction.handle_remove_ap)(instance.message)
pass
case "move": case "move":
# We're ignoring moves for now # We're ignoring moves for now
pass pass

View file

@ -1,7 +1,7 @@
from django.db import models from django.db import models, transaction
from django.template.defaultfilters import linebreaks_filter 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.files import resize_image
from core.html import FediverseHtmlParser from core.html import FediverseHtmlParser
from users.models import ( 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): def mastodon_json_relationship(self, from_identity: Identity):
""" """
Returns a Relationship object for the from_identity's relationship Returns a Relationship object for the from_identity's relationship

View file

@ -9,6 +9,7 @@ from django.views.decorators.csrf import csrf_exempt
from django.views.generic import View from django.views.generic import View
from activities.models import Post from activities.models import Post
from activities.services import TimelineService
from core import exceptions from core import exceptions
from core.decorators import cache_page from core.decorators import cache_page
from core.ld import canonicalise 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") @method_decorator(cache_control(max_age=60 * 15), name="dispatch")
class EmptyOutbox(StaticContentView): class EmptyOutbox(StaticContentView):
""" """

View file

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