Bookmarks (#537)

This commit is contained in:
Dan Watson 2023-03-11 13:17:20 -05:00 committed by GitHub
parent 758e6633c4
commit cedcc8fa7c
No known key found for this signature in database
GPG key ID: 4AEE18F83AFDEB23
16 changed files with 233 additions and 9 deletions

View file

@ -329,6 +329,8 @@ class Post(StatorModel):
action_unlike = "{view}unlike/" action_unlike = "{view}unlike/"
action_boost = "{view}boost/" action_boost = "{view}boost/"
action_unboost = "{view}unboost/" action_unboost = "{view}unboost/"
action_bookmark = "{view}bookmark/"
action_unbookmark = "{view}unbookmark/"
action_delete = "{view}delete/" action_delete = "{view}delete/"
action_edit = "{view}edit/" action_edit = "{view}edit/"
action_report = "{view}report/" action_report = "{view}report/"
@ -1059,7 +1061,7 @@ class Post(StatorModel):
### Mastodon API ### ### Mastodon API ###
def to_mastodon_json(self, interactions=None, identity=None): def to_mastodon_json(self, interactions=None, bookmarks=None, identity=None):
reply_parent = None reply_parent = None
if self.in_reply_to: if self.in_reply_to:
# Load the PK and author.id explicitly to prevent a SELECT on the entire author Identity # Load the PK and author.id explicitly to prevent a SELECT on the entire author Identity
@ -1128,4 +1130,6 @@ 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", [])
if bookmarks:
value["bookmarked"] = self.pk in bookmarks
return value return value

View file

@ -221,10 +221,10 @@ class TimelineEvent(models.Model):
raise ValueError(f"Cannot convert {self.type} to notification JSON") raise ValueError(f"Cannot convert {self.type} to notification JSON")
return result return result
def to_mastodon_status_json(self, interactions=None, identity=None): def to_mastodon_status_json(self, interactions=None, bookmarks=None, identity=None):
if self.type == self.Types.post: if self.type == self.Types.post:
return self.subject_post.to_mastodon_json( return self.subject_post.to_mastodon_json(
interactions=interactions, identity=identity interactions=interactions, bookmarks=bookmarks, identity=identity
) )
elif self.type == self.Types.boost: elif self.type == self.Types.boost:
return self.subject_post_interaction.to_mastodon_status_json( return self.subject_post_interaction.to_mastodon_status_json(

View file

@ -102,3 +102,13 @@ class TimelineService:
) )
.order_by("-id") .order_by("-id")
) )
def bookmarks(self) -> models.QuerySet[Post]:
"""
Return all bookmarked posts for an identity
"""
return (
PostService.queryset()
.filter(bookmarks__identity=self.identity)
.order_by("-id")
)

View file

@ -146,6 +146,39 @@ class Boost(View):
return redirect(post.urls.view) return redirect(post.urls.view)
@method_decorator(identity_required, name="dispatch")
class Bookmark(View):
"""
Adds/removes a bookmark from the current identity to the post
"""
undo = False
def post(self, request, handle, post_id):
identity = by_handle_or_404(self.request, handle, local=False)
post = get_object_or_404(
PostService.queryset()
.filter(author=identity)
.visible_to(request.identity, include_replies=True),
pk=post_id,
)
if self.undo:
request.identity.bookmarks.filter(post=post).delete()
else:
request.identity.bookmarks.get_or_create(post=post)
# Return either a redirect or a HTMX snippet
if request.htmx:
return render(
request,
"activities/_bookmark.html",
{
"post": post,
"bookmarks": set() if self.undo else {post.pk},
},
)
return redirect(post.urls.view)
@method_decorator(identity_required, name="dispatch") @method_decorator(identity_required, name="dispatch")
class Delete(TemplateView): class Delete(TemplateView):
""" """

View file

@ -7,6 +7,7 @@ from activities.models import Hashtag, PostInteraction, TimelineEvent
from activities.services import TimelineService from activities.services import TimelineService
from core.decorators import cache_page from core.decorators import cache_page
from users.decorators import identity_required from users.decorators import identity_required
from users.models import Bookmark
from .compose import Compose from .compose import Compose
@ -31,6 +32,9 @@ class Home(TemplateView):
event_page, event_page,
self.request.identity, self.request.identity,
), ),
"bookmarks": Bookmark.for_identity(
self.request.identity, event_page, "subject_post_id"
),
"current_page": "home", "current_page": "home",
"allows_refresh": True, "allows_refresh": True,
"page_obj": event_page, "page_obj": event_page,
@ -68,6 +72,9 @@ class Tag(ListView):
context["interactions"] = PostInteraction.get_post_interactions( context["interactions"] = PostInteraction.get_post_interactions(
context["page_obj"], self.request.identity context["page_obj"], self.request.identity
) )
context["bookmarks"] = Bookmark.for_identity(
self.request.identity, context["page_obj"]
)
return context return context
@ -91,6 +98,9 @@ class Local(ListView):
context["interactions"] = PostInteraction.get_post_interactions( context["interactions"] = PostInteraction.get_post_interactions(
context["page_obj"], self.request.identity context["page_obj"], self.request.identity
) )
context["bookmarks"] = Bookmark.for_identity(
self.request.identity, context["page_obj"]
)
return context return context
@ -112,6 +122,9 @@ class Federated(ListView):
context["interactions"] = PostInteraction.get_post_interactions( context["interactions"] = PostInteraction.get_post_interactions(
context["page_obj"], self.request.identity context["page_obj"], self.request.identity
) )
context["bookmarks"] = Bookmark.for_identity(
self.request.identity, context["page_obj"]
)
return context return context
@ -173,4 +186,7 @@ class Notifications(ListView):
context["page_obj"], context["page_obj"],
self.request.identity, self.request.identity,
) )
context["bookmarks"] = Bookmark.for_identity(
self.request.identity, context["page_obj"], "subject_post_id"
)
return context return context

View file

@ -165,10 +165,13 @@ class Status(Schema):
cls, cls,
post: activities_models.Post, post: activities_models.Post,
interactions: dict[str, set[str]] | None = None, interactions: dict[str, set[str]] | None = None,
bookmarks: set[str] | None = None,
identity: users_models.Identity | None = None, identity: users_models.Identity | None = None,
) -> "Status": ) -> "Status":
return cls( return cls(
**post.to_mastodon_json(interactions=interactions, identity=identity) **post.to_mastodon_json(
interactions=interactions, bookmarks=bookmarks, identity=identity
)
) )
@classmethod @classmethod
@ -180,8 +183,11 @@ class Status(Schema):
interactions = activities_models.PostInteraction.get_post_interactions( interactions = activities_models.PostInteraction.get_post_interactions(
posts, identity posts, identity
) )
bookmarks = users_models.Bookmark.for_identity(identity, posts)
return [ return [
cls.from_post(post, interactions=interactions, identity=identity) cls.from_post(
post, interactions=interactions, bookmarks=bookmarks, identity=identity
)
for post in posts for post in posts
] ]
@ -190,11 +196,12 @@ class Status(Schema):
cls, cls,
timeline_event: activities_models.TimelineEvent, timeline_event: activities_models.TimelineEvent,
interactions: dict[str, set[str]] | None = None, interactions: dict[str, set[str]] | None = None,
bookmarks: set[str] | None = None,
identity: users_models.Identity | None = None, identity: users_models.Identity | None = None,
) -> "Status": ) -> "Status":
return cls( return cls(
**timeline_event.to_mastodon_status_json( **timeline_event.to_mastodon_status_json(
interactions=interactions, identity=identity interactions=interactions, bookmarks=bookmarks, identity=identity
) )
) )
@ -207,8 +214,13 @@ class Status(Schema):
interactions = activities_models.PostInteraction.get_event_interactions( interactions = activities_models.PostInteraction.get_event_interactions(
events, identity events, identity
) )
bookmarks = users_models.Bookmark.for_identity(
identity, events, "subject_post_id"
)
return [ return [
cls.from_timeline_event(event, interactions=interactions, identity=identity) cls.from_timeline_event(
event, interactions=interactions, bookmarks=bookmarks, identity=identity
)
for event in events for event in events
] ]

View file

@ -91,6 +91,8 @@ urlpatterns = [
path("v1/statuses/<id>/favourited_by", statuses.favourited_by), path("v1/statuses/<id>/favourited_by", statuses.favourited_by),
path("v1/statuses/<id>/reblog", statuses.reblog_status), path("v1/statuses/<id>/reblog", statuses.reblog_status),
path("v1/statuses/<id>/unreblog", statuses.unreblog_status), path("v1/statuses/<id>/unreblog", statuses.unreblog_status),
path("v1/statuses/<id>/bookmark", statuses.bookmark_status),
path("v1/statuses/<id>/unbookmark", statuses.unbookmark_status),
# Tags # Tags
path("v1/followed_tags", tags.followed_tags), path("v1/followed_tags", tags.followed_tags),
# Timelines # Timelines

View file

@ -1,8 +1,11 @@
from django.http import HttpRequest from django.http import HttpRequest
from hatchway import api_view from hatchway import api_view
from activities.models import Post
from activities.services import TimelineService
from api import schemas from api import schemas
from api.decorators import scope_required from api.decorators import scope_required
from api.pagination import MastodonPaginator, PaginatingApiResponse, PaginationResult
@scope_required("read:bookmarks") @scope_required("read:bookmarks")
@ -14,5 +17,17 @@ def bookmarks(
min_id: str | None = None, min_id: str | None = None,
limit: int = 20, limit: int = 20,
) -> list[schemas.Status]: ) -> list[schemas.Status]:
# We don't implement this yet queryset = TimelineService(request.identity).bookmarks()
return [] paginator = MastodonPaginator()
pager: PaginationResult[Post] = paginator.paginate(
queryset,
min_id=min_id,
max_id=max_id,
since_id=since_id,
limit=limit,
)
return PaginatingApiResponse(
schemas.Status.map_from_post(pager.results, request.identity),
request=request,
include_params=["limit"],
)

View file

@ -267,3 +267,25 @@ def unreblog_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:bookmarks")
@api_view.post
def bookmark_status(request, id: str) -> schemas.Status:
post = post_for_id(request, id)
request.identity.bookmarks.get_or_create(post=post)
interactions = PostInteraction.get_post_interactions([post], request.identity)
return schemas.Status.from_post(
post, interactions=interactions, bookmarks={post.pk}, identity=request.identity
)
@scope_required("write:bookmarks")
@api_view.post
def unbookmark_status(request, id: str) -> schemas.Status:
post = post_for_id(request, id)
request.identity.bookmarks.filter(post=post).delete()
interactions = PostInteraction.get_post_interactions([post], request.identity)
return schemas.Status.from_post(
post, interactions=interactions, identity=request.identity
)

View file

@ -242,6 +242,10 @@ urlpatterns = [
path("@<handle>/posts/<int:post_id>/unlike/", posts.Like.as_view(undo=True)), path("@<handle>/posts/<int:post_id>/unlike/", posts.Like.as_view(undo=True)),
path("@<handle>/posts/<int:post_id>/boost/", posts.Boost.as_view()), path("@<handle>/posts/<int:post_id>/boost/", posts.Boost.as_view()),
path("@<handle>/posts/<int:post_id>/unboost/", posts.Boost.as_view(undo=True)), path("@<handle>/posts/<int:post_id>/unboost/", posts.Boost.as_view(undo=True)),
path("@<handle>/posts/<int:post_id>/bookmark/", posts.Bookmark.as_view()),
path(
"@<handle>/posts/<int:post_id>/unbookmark/", posts.Bookmark.as_view(undo=True)
),
path("@<handle>/posts/<int:post_id>/delete/", posts.Delete.as_view()), path("@<handle>/posts/<int:post_id>/delete/", posts.Delete.as_view()),
path("@<handle>/posts/<int:post_id>/report/", report.SubmitReport.as_view()), path("@<handle>/posts/<int:post_id>/report/", report.SubmitReport.as_view()),
path("@<handle>/posts/<int:post_id>/edit/", compose.Compose.as_view()), path("@<handle>/posts/<int:post_id>/edit/", compose.Compose.as_view()),

View file

@ -0,0 +1,9 @@
{% if post.pk in bookmarks %}
<a title="Unbookmark" class="active" hx-trigger="click, keyup[key=='Enter']" hx-post="{{ post.urls.action_unbookmark }}" hx-swap="outerHTML" tabindex="0">
<i class="fa-solid fa-bookmark"></i>
</a>
{% else %}
<a title="Bookmark" hx-trigger="click, keyup[key=='Enter']" hx-post="{{ post.urls.action_bookmark }}" hx-swap="outerHTML" tabindex="0">
<i class="fa-regular fa-bookmark"></i>
</a>
{% endif %}

View file

@ -82,6 +82,7 @@
{% include "activities/_reply.html" %} {% include "activities/_reply.html" %}
{% include "activities/_like.html" %} {% include "activities/_like.html" %}
{% include "activities/_boost.html" %} {% include "activities/_boost.html" %}
{% include "activities/_bookmark.html" %}
<a title="Menu" class="menu" _="on click or keyup[key is 'Enter'] toggle .enabled on the next <menu/> then halt" role="menuitem" aria-haspopup="menu" tabindex="0"> <a title="Menu" class="menu" _="on click or keyup[key is 'Enter'] toggle .enabled on the next <menu/> then halt" role="menuitem" aria-haspopup="menu" tabindex="0">
<i class="fa-solid fa-bars"></i> <i class="fa-solid fa-bars"></i>
</a> </a>

View file

@ -8,6 +8,7 @@ from activities.admin import IdentityLocalFilter
from users.models import ( from users.models import (
Announcement, Announcement,
Block, Block,
Bookmark,
Domain, Domain,
Follow, Follow,
Identity, Identity,
@ -221,3 +222,9 @@ class ReportAdmin(admin.ModelAdmin):
class AnnouncementAdmin(admin.ModelAdmin): class AnnouncementAdmin(admin.ModelAdmin):
list_display = ["id", "published", "start", "end", "text"] list_display = ["id", "published", "start", "end", "text"]
autocomplete_fields = ["seen"] autocomplete_fields = ["seen"]
@admin.register(Bookmark)
class BookmarkAdmin(admin.ModelAdmin):
list_display = ["id", "identity", "post", "created"]
raw_id_fields = ["identity", "post"]

View file

@ -0,0 +1,49 @@
# Generated by Django 4.1.7 on 2023-03-11 00:11
import django.db.models.deletion
from django.db import migrations, models
class Migration(migrations.Migration):
dependencies = [
("activities", "0012_in_reply_to_index"),
("users", "0014_domain_notes"),
]
operations = [
migrations.CreateModel(
name="Bookmark",
fields=[
(
"id",
models.BigAutoField(
auto_created=True,
primary_key=True,
serialize=False,
verbose_name="ID",
),
),
("created", models.DateTimeField(auto_now_add=True)),
(
"identity",
models.ForeignKey(
on_delete=django.db.models.deletion.CASCADE,
related_name="bookmarks",
to="users.identity",
),
),
(
"post",
models.ForeignKey(
on_delete=django.db.models.deletion.CASCADE,
related_name="bookmarks",
to="activities.post",
),
),
],
options={
"unique_together": {("identity", "post")},
},
),
]

View file

@ -1,5 +1,6 @@
from .announcement import Announcement # noqa from .announcement import Announcement # noqa
from .block import Block, BlockStates # noqa from .block import Block, BlockStates # noqa
from .bookmark import Bookmark # noqa
from .domain import Domain # noqa from .domain import Domain # noqa
from .follow import Follow, FollowStates # noqa from .follow import Follow, FollowStates # noqa
from .identity import Identity, IdentityStates # noqa from .identity import Identity, IdentityStates # noqa

39
users/models/bookmark.py Normal file
View file

@ -0,0 +1,39 @@
from django.db import models
class Bookmark(models.Model):
"""
A (private) bookmark of a Post by an Identity
"""
identity = models.ForeignKey(
"users.Identity",
on_delete=models.CASCADE,
related_name="bookmarks",
)
post = models.ForeignKey(
"activities.Post",
on_delete=models.CASCADE,
related_name="bookmarks",
)
created = models.DateTimeField(auto_now_add=True)
class Meta:
unique_together = [("identity", "post")]
def __str__(self):
return f"#{self.id}: {self.identity}{self.post}"
@classmethod
def for_identity(cls, identity, posts=None, field="id"):
"""
Returns a set of bookmarked Post IDs for the given identity. If `posts` is
specified, it is used to filter bookmarks matching those in the list.
"""
if identity is None:
return set()
queryset = cls.objects.filter(identity=identity)
if posts:
queryset = queryset.filter(post_id__in=[getattr(p, field) for p in posts])
return set(queryset.values_list("post_id", flat=True))