mirror of
https://github.com/jointakahe/takahe.git
synced 2024-11-29 02:31:00 +00:00
Pinned posts (#561)
This commit is contained in:
parent
744c2825d9
commit
d6c9ba0819
24 changed files with 586 additions and 31 deletions
26
activities/migrations/0015_alter_postinteraction_type.py
Normal file
26
activities/migrations/0015_alter_postinteraction_type.py
Normal file
|
@ -0,0 +1,26 @@
|
||||||
|
# Generated by Django 4.1.7 on 2023-04-24 08:04
|
||||||
|
|
||||||
|
from django.db import migrations, models
|
||||||
|
|
||||||
|
|
||||||
|
class Migration(migrations.Migration):
|
||||||
|
|
||||||
|
dependencies = [
|
||||||
|
("activities", "0014_post_content_vector_gin"),
|
||||||
|
]
|
||||||
|
|
||||||
|
operations = [
|
||||||
|
migrations.AlterField(
|
||||||
|
model_name="postinteraction",
|
||||||
|
name="type",
|
||||||
|
field=models.CharField(
|
||||||
|
choices=[
|
||||||
|
("like", "Like"),
|
||||||
|
("boost", "Boost"),
|
||||||
|
("vote", "Vote"),
|
||||||
|
("pin", "Pin"),
|
||||||
|
],
|
||||||
|
max_length=100,
|
||||||
|
),
|
||||||
|
),
|
||||||
|
]
|
|
@ -163,22 +163,24 @@ class FanOutStates(StateGraph):
|
||||||
interaction=interaction,
|
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
|
||||||
|
|
|
@ -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
|
||||||
|
|
|
@ -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):
|
||||||
|
|
|
@ -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)
|
||||||
|
|
|
@ -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
|
||||||
|
|
|
@ -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
|
||||||
]
|
]
|
||||||
|
|
|
@ -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),
|
||||||
|
|
|
@ -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:
|
||||||
|
|
|
@ -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
|
||||||
|
)
|
||||||
|
|
|
@ -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:
|
||||||
|
|
|
@ -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;
|
||||||
|
|
|
@ -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)),
|
||||||
|
|
|
@ -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 %}
|
||||||
|
|
|
@ -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"})
|
||||||
|
|
|
@ -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
|
||||||
|
)
|
||||||
|
|
|
@ -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",
|
||||||
|
|
|
@ -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>"
|
||||||
|
|
18
users/migrations/0017_identity_featured_collection_uri.py
Normal file
18
users/migrations/0017_identity_featured_collection_uri.py
Normal file
|
@ -0,0 +1,18 @@
|
||||||
|
# Generated by Django 4.1.7 on 2023-04-23 20:04
|
||||||
|
|
||||||
|
from django.db import migrations, models
|
||||||
|
|
||||||
|
|
||||||
|
class Migration(migrations.Migration):
|
||||||
|
|
||||||
|
dependencies = [
|
||||||
|
("users", "0016_hashtagfollow"),
|
||||||
|
]
|
||||||
|
|
||||||
|
operations = [
|
||||||
|
migrations.AddField(
|
||||||
|
model_name="identity",
|
||||||
|
name="featured_collection_uri",
|
||||||
|
field=models.CharField(blank=True, max_length=500, null=True),
|
||||||
|
),
|
||||||
|
]
|
|
@ -188,6 +188,7 @@ class Identity(StatorModel):
|
||||||
image_uri = models.CharField(max_length=500, blank=True, null=True)
|
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 ###
|
||||||
|
|
|
@ -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
|
||||||
|
|
|
@ -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
|
||||||
|
|
|
@ -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):
|
||||||
"""
|
"""
|
||||||
|
|
|
@ -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()
|
||||||
|
|
Loading…
Reference in a new issue