Add expanded post context

Fixes #120
This commit is contained in:
Andrew Godwin 2022-12-20 09:59:06 +00:00
parent e28294c81a
commit 9067caf9a3
7 changed files with 185 additions and 95 deletions

View file

@ -128,6 +128,28 @@ class PostQuerySet(models.QuerySet):
return query.filter(in_reply_to__isnull=True) return query.filter(in_reply_to__isnull=True)
return query return query
def visible_to(self, identity, include_replies: bool = False):
query = self.filter(
models.Q(
visibility__in=[
Post.Visibilities.public,
Post.Visibilities.local_only,
Post.Visibilities.unlisted,
]
)
| models.Q(
visibility=Post.Visibilities.followers,
author__inbound_follows__source=identity,
)
| models.Q(
visibility=Post.Visibilities.mentioned,
mentions=identity,
)
).distinct()
if not include_replies:
return query.filter(in_reply_to__isnull=True)
return query
def tagged_with(self, hashtag: str | Hashtag): def tagged_with(self, hashtag: str | Hashtag):
if isinstance(hashtag, str): if isinstance(hashtag, str):
tag_q = models.Q(hashtags__contains=hashtag) tag_q = models.Q(hashtags__contains=hashtag)
@ -526,48 +548,6 @@ class Post(StatorModel):
hashtag=hashtag, hashtag=hashtag,
) )
### Actions ###
def interact_as(self, identity, type):
from activities.models import PostInteraction, PostInteractionStates
interaction = PostInteraction.objects.get_or_create(
type=type, identity=identity, post=self
)[0]
if interaction.state in [
PostInteractionStates.undone,
PostInteractionStates.undone_fanned_out,
]:
interaction.transition_perform(PostInteractionStates.new)
def uninteract_as(self, identity, type):
from activities.models import PostInteraction, PostInteractionStates
for interaction in PostInteraction.objects.filter(
type=type, identity=identity, post=self
):
interaction.transition_perform(PostInteractionStates.undone)
def like_as(self, identity):
from activities.models import PostInteraction
self.interact_as(identity, PostInteraction.Types.like)
def unlike_as(self, identity):
from activities.models import PostInteraction
self.uninteract_as(identity, PostInteraction.Types.like)
def boost_as(self, identity):
from activities.models import PostInteraction
self.interact_as(identity, PostInteraction.Types.boost)
def unboost_as(self, identity):
from activities.models import PostInteraction
self.uninteract_as(identity, PostInteraction.Types.boost)
### ActivityPub (outbound) ### ### ActivityPub (outbound) ###
def to_ap(self) -> dict: def to_ap(self) -> dict:

View file

@ -0,0 +1 @@
from .post import PostService # noqa

View file

@ -0,0 +1,94 @@
from typing import cast
from activities.models import Post, PostInteraction, PostInteractionStates, PostStates
from users.models import Identity
class PostService:
"""
High-level operations on Posts
"""
def __init__(self, post: Post):
self.post = post
def interact_as(self, identity: Identity, type: str):
"""
Performs an interaction on this Post
"""
interaction = PostInteraction.objects.get_or_create(
type=type,
identity=identity,
post=self.post,
)[0]
if interaction.state in [
PostInteractionStates.undone,
PostInteractionStates.undone_fanned_out,
]:
interaction.transition_perform(PostInteractionStates.new)
def uninteract_as(self, identity, type):
"""
Undoes an interaction on this Post
"""
for interaction in PostInteraction.objects.filter(
type=type,
identity=identity,
post=self.post,
):
interaction.transition_perform(PostInteractionStates.undone)
def like_as(self, identity: Identity):
self.interact_as(identity, PostInteraction.Types.like)
def unlike_as(self, identity: Identity):
self.uninteract_as(identity, PostInteraction.Types.like)
def boost_as(self, identity: Identity):
self.interact_as(identity, PostInteraction.Types.boost)
def unboost_as(self, identity: Identity):
self.uninteract_as(identity, PostInteraction.Types.boost)
def context(self, identity: Identity | None) -> tuple[list[Post], list[Post]]:
"""
Returns ancestor/descendant information.
Ancestors are guaranteed to be in order from closest to furthest.
Descendants are in depth-first order, starting with closest.
If identity is provided, includes mentions/followers-only posts they
can see. Otherwise, shows unlisted and above only.
"""
num_ancestors = 10
num_descendants = 50
# Retrieve ancestors via parent walk
ancestors: list[Post] = []
ancestor = self.post
while ancestor.in_reply_to and len(ancestors) < num_ancestors:
ancestor = cast(Post, ancestor.in_reply_to_post())
if ancestor.state in [PostStates.deleted, PostStates.deleted_fanned_out]:
break
ancestors.append(ancestor)
# Retrieve descendants via breadth-first-search
descendants: list[Post] = []
queue = [self.post]
while queue and len(descendants) < num_descendants:
node = queue.pop()
child_queryset = (
Post.objects.not_hidden()
.filter(in_reply_to=node.object_uri)
.select_related("author", "author__domain")
.prefetch_related("emojis")
.order_by("published")
)
if identity:
child_queryset = child_queryset.visible_to(
identity=identity, include_replies=True
)
else:
child_queryset = child_queryset.unlisted(include_replies=True)
for child in child_queryset:
descendants.append(child)
queue.append(child)
return ancestors, descendants

View file

@ -1,16 +1,15 @@
from django.core.exceptions import PermissionDenied from django.core.exceptions import PermissionDenied
from django.db import models
from django.http import Http404, JsonResponse from django.http import Http404, JsonResponse
from django.shortcuts import get_object_or_404, redirect, render from django.shortcuts import get_object_or_404, redirect, render
from django.utils.decorators import method_decorator from django.utils.decorators import method_decorator
from django.views.decorators.vary import vary_on_headers from django.views.decorators.vary import vary_on_headers
from django.views.generic import TemplateView, View from django.views.generic import TemplateView, View
from activities.models import Post, PostInteraction, PostStates from activities.models import PostInteraction, PostStates
from activities.services import PostService
from core.decorators import cache_page_by_ap_json from core.decorators import cache_page_by_ap_json
from core.ld import canonicalise from core.ld import canonicalise
from users.decorators import identity_required from users.decorators import identity_required
from users.models import Identity
from users.shortcuts import by_handle_or_404 from users.shortcuts import by_handle_or_404
@ -38,44 +37,19 @@ class Individual(TemplateView):
return super().get(request) return super().get(request)
def get_context_data(self): def get_context_data(self):
parent = None ancestors, descendants = PostService(self.post_obj).context(
if self.post_obj.in_reply_to: self.request.identity
try: )
parent = Post.by_object_uri(self.post_obj.in_reply_to, fetch=True)
except Post.DoesNotExist:
pass
return { return {
"identity": self.identity, "identity": self.identity,
"post": self.post_obj, "post": self.post_obj,
"interactions": PostInteraction.get_post_interactions( "interactions": PostInteraction.get_post_interactions(
[self.post_obj], [self.post_obj] + ancestors + descendants,
self.request.identity, self.request.identity,
), ),
"link_original": True, "link_original": True,
"parent": parent, "ancestors": ancestors,
"replies": Post.objects.filter( "descendants": descendants,
models.Q(
visibility__in=[
Post.Visibilities.public,
Post.Visibilities.local_only,
Post.Visibilities.unlisted,
]
)
| models.Q(
visibility=Post.Visibilities.followers,
author__inbound_follows__source=self.identity,
)
| models.Q(
visibility=Post.Visibilities.mentioned,
mentions=self.identity,
),
in_reply_to=self.post_obj.object_uri,
)
.exclude(author__restriction=Identity.Restriction.blocked)
.distinct()
.select_related("author__domain")
.prefetch_related("emojis")
.order_by("published", "created"),
} }
def serve_object(self): def serve_object(self):
@ -101,10 +75,11 @@ class Like(View):
post = get_object_or_404( post = get_object_or_404(
identity.posts.prefetch_related("attachments"), pk=post_id identity.posts.prefetch_related("attachments"), pk=post_id
) )
service = PostService(post)
if self.undo: if self.undo:
post.unlike_as(self.request.identity) service.unlike_as(self.request.identity)
else: else:
post.like_as(self.request.identity) service.like_as(self.request.identity)
# Return either a redirect or a HTMX snippet # Return either a redirect or a HTMX snippet
if request.htmx: if request.htmx:
return render( return render(
@ -129,10 +104,11 @@ class Boost(View):
def post(self, request, handle, post_id): def post(self, request, handle, post_id):
identity = by_handle_or_404(self.request, handle, local=False) identity = by_handle_or_404(self.request, handle, local=False)
post = get_object_or_404(identity.posts, pk=post_id) post = get_object_or_404(identity.posts, pk=post_id)
service = PostService(post)
if self.undo: if self.undo:
post.unboost_as(request.identity) service.unboost_as(request.identity)
else: else:
post.boost_as(request.identity) service.boost_as(request.identity)
# Return either a redirect or a HTMX snippet # Return either a redirect or a HTMX snippet
if request.htmx: if request.htmx:
return render( return render(

View file

@ -11,6 +11,7 @@ from activities.models import (
PostStates, PostStates,
TimelineEvent, TimelineEvent,
) )
from activities.services import PostService
from api import schemas from api import schemas
from api.views.base import api_router from api.views.base import api_router
from core.models import Config from core.models import Config
@ -87,13 +88,10 @@ def delete_status(request, id: str):
@identity_required @identity_required
def status_context(request, id: str): def status_context(request, id: str):
post = get_object_or_404(Post, pk=id) post = get_object_or_404(Post, pk=id)
parent = post.in_reply_to_post() service = PostService(post)
ancestors = [] ancestors, descendants = service.context(request.identity)
if parent:
ancestors.append(parent)
descendants = list(Post.objects.filter(in_reply_to=post.object_uri)[:40])
interactions = PostInteraction.get_post_interactions( interactions = PostInteraction.get_post_interactions(
[post] + ancestors + descendants, request.identity ancestors + descendants, request.identity
) )
return { return {
"ancestors": [p.to_mastodon_json(interactions=interactions) for p in ancestors], "ancestors": [p.to_mastodon_json(interactions=interactions) for p in ancestors],
@ -107,7 +105,8 @@ def status_context(request, id: str):
@identity_required @identity_required
def favourite_status(request, id: str): def favourite_status(request, id: str):
post = get_object_or_404(Post, pk=id) post = get_object_or_404(Post, pk=id)
post.like_as(request.identity) service = PostService(post)
service.like_as(request.identity)
interactions = PostInteraction.get_post_interactions([post], request.identity) interactions = PostInteraction.get_post_interactions([post], request.identity)
return post.to_mastodon_json(interactions=interactions) return post.to_mastodon_json(interactions=interactions)
@ -116,7 +115,8 @@ def favourite_status(request, id: str):
@identity_required @identity_required
def unfavourite_status(request, id: str): def unfavourite_status(request, id: str):
post = get_object_or_404(Post, pk=id) post = get_object_or_404(Post, pk=id)
post.unlike_as(request.identity) service = PostService(post)
service.unlike_as(request.identity)
interactions = PostInteraction.get_post_interactions([post], request.identity) interactions = PostInteraction.get_post_interactions([post], request.identity)
return post.to_mastodon_json(interactions=interactions) return post.to_mastodon_json(interactions=interactions)
@ -125,7 +125,8 @@ def unfavourite_status(request, id: str):
@identity_required @identity_required
def reblog_status(request, id: str): def reblog_status(request, id: str):
post = get_object_or_404(Post, pk=id) post = get_object_or_404(Post, pk=id)
post.boost_as(request.identity) service = PostService(post)
service.boost_as(request.identity)
interactions = PostInteraction.get_post_interactions([post], request.identity) interactions = PostInteraction.get_post_interactions([post], request.identity)
return post.to_mastodon_json(interactions=interactions) return post.to_mastodon_json(interactions=interactions)
@ -134,6 +135,7 @@ def reblog_status(request, id: str):
@identity_required @identity_required
def unreblog_status(request, id: str): def unreblog_status(request, id: str):
post = get_object_or_404(Post, pk=id) post = get_object_or_404(Post, pk=id)
post.unboost_as(request.identity) service = PostService(post)
service.unboost_as(request.identity)
interactions = PostInteraction.get_post_interactions([post], request.identity) interactions = PostInteraction.get_post_interactions([post], request.identity)
return post.to_mastodon_json(interactions=interactions) return post.to_mastodon_json(interactions=interactions)

View file

@ -3,11 +3,11 @@
{% block title %}Post by {{ post.author.html_name_or_handle }}{% endblock %} {% block title %}Post by {{ post.author.html_name_or_handle }}{% endblock %}
{% block content %} {% block content %}
{% if parent %} {% for ancestor in ancestors reversed %}
{% include "activities/_post.html" with post=parent reply=True link_original=False %} {% include "activities/_post.html" with post=ancestor reply=True link_original=False %}
{% endif %} {% endfor %}
{% include "activities/_post.html" %} {% include "activities/_post.html" %}
{% for reply in replies %} {% for descendant in descendants %}
{% include "activities/_post.html" with post=reply reply=True link_original=False %} {% include "activities/_post.html" with post=descendant reply=True link_original=False %}
{% endfor %} {% endfor %}
{% endblock %} {% endblock %}

View file

@ -0,0 +1,37 @@
import pytest
from activities.models import Post
from activities.services import PostService
from users.models import Identity
@pytest.mark.django_db
def test_post_context(identity: Identity):
"""
Tests that post context fetching works correctly
"""
post1 = Post.create_local(
author=identity,
content="<p>first</p>",
visibility=Post.Visibilities.public,
)
post2 = Post.create_local(
author=identity,
content="<p>second</p>",
visibility=Post.Visibilities.public,
reply_to=post1,
)
post3 = Post.create_local(
author=identity,
content="<p>third</p>",
visibility=Post.Visibilities.public,
reply_to=post2,
)
# Test the view from the start of thread
ancestors, descendants = PostService(post1).context(None)
assert ancestors == []
assert descendants == [post2, post3]
# Test the view from the end of thread
ancestors, descendants = PostService(post3).context(None)
assert ancestors == [post2, post1]
assert descendants == []