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,
)
# 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

View File

@ -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

View File

@ -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):

View File

@ -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)

View File

@ -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

View File

@ -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
]

View File

@ -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),

View File

@ -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:

View File

@ -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
)

View File

@ -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:

View File

@ -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;

View File

@ -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)),

View File

@ -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 %}

View File

@ -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"})

View File

@ -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
)

View File

@ -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",

View File

@ -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>"

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)
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 ###

View File

@ -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

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 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

View File

@ -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):
"""

View File

@ -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()