Bookmarks (#537)
This commit is contained in:
parent
758e6633c4
commit
cedcc8fa7c
|
@ -329,6 +329,8 @@ class Post(StatorModel):
|
|||
action_unlike = "{view}unlike/"
|
||||
action_boost = "{view}boost/"
|
||||
action_unboost = "{view}unboost/"
|
||||
action_bookmark = "{view}bookmark/"
|
||||
action_unbookmark = "{view}unbookmark/"
|
||||
action_delete = "{view}delete/"
|
||||
action_edit = "{view}edit/"
|
||||
action_report = "{view}report/"
|
||||
|
@ -1059,7 +1061,7 @@ class Post(StatorModel):
|
|||
|
||||
### 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
|
||||
if self.in_reply_to:
|
||||
# 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:
|
||||
value["favourited"] = self.pk in interactions.get("like", [])
|
||||
value["reblogged"] = self.pk in interactions.get("boost", [])
|
||||
if bookmarks:
|
||||
value["bookmarked"] = self.pk in bookmarks
|
||||
return value
|
||||
|
|
|
@ -221,10 +221,10 @@ class TimelineEvent(models.Model):
|
|||
raise ValueError(f"Cannot convert {self.type} to notification JSON")
|
||||
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:
|
||||
return self.subject_post.to_mastodon_json(
|
||||
interactions=interactions, identity=identity
|
||||
interactions=interactions, bookmarks=bookmarks, identity=identity
|
||||
)
|
||||
elif self.type == self.Types.boost:
|
||||
return self.subject_post_interaction.to_mastodon_status_json(
|
||||
|
|
|
@ -102,3 +102,13 @@ class TimelineService:
|
|||
)
|
||||
.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")
|
||||
)
|
||||
|
|
|
@ -146,6 +146,39 @@ class Boost(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")
|
||||
class Delete(TemplateView):
|
||||
"""
|
||||
|
|
|
@ -7,6 +7,7 @@ from activities.models import Hashtag, PostInteraction, TimelineEvent
|
|||
from activities.services import TimelineService
|
||||
from core.decorators import cache_page
|
||||
from users.decorators import identity_required
|
||||
from users.models import Bookmark
|
||||
|
||||
from .compose import Compose
|
||||
|
||||
|
@ -31,6 +32,9 @@ class Home(TemplateView):
|
|||
event_page,
|
||||
self.request.identity,
|
||||
),
|
||||
"bookmarks": Bookmark.for_identity(
|
||||
self.request.identity, event_page, "subject_post_id"
|
||||
),
|
||||
"current_page": "home",
|
||||
"allows_refresh": True,
|
||||
"page_obj": event_page,
|
||||
|
@ -68,6 +72,9 @@ class Tag(ListView):
|
|||
context["interactions"] = PostInteraction.get_post_interactions(
|
||||
context["page_obj"], self.request.identity
|
||||
)
|
||||
context["bookmarks"] = Bookmark.for_identity(
|
||||
self.request.identity, context["page_obj"]
|
||||
)
|
||||
return context
|
||||
|
||||
|
||||
|
@ -91,6 +98,9 @@ class Local(ListView):
|
|||
context["interactions"] = PostInteraction.get_post_interactions(
|
||||
context["page_obj"], self.request.identity
|
||||
)
|
||||
context["bookmarks"] = Bookmark.for_identity(
|
||||
self.request.identity, context["page_obj"]
|
||||
)
|
||||
return context
|
||||
|
||||
|
||||
|
@ -112,6 +122,9 @@ class Federated(ListView):
|
|||
context["interactions"] = PostInteraction.get_post_interactions(
|
||||
context["page_obj"], self.request.identity
|
||||
)
|
||||
context["bookmarks"] = Bookmark.for_identity(
|
||||
self.request.identity, context["page_obj"]
|
||||
)
|
||||
return context
|
||||
|
||||
|
||||
|
@ -173,4 +186,7 @@ class Notifications(ListView):
|
|||
context["page_obj"],
|
||||
self.request.identity,
|
||||
)
|
||||
context["bookmarks"] = Bookmark.for_identity(
|
||||
self.request.identity, context["page_obj"], "subject_post_id"
|
||||
)
|
||||
return context
|
||||
|
|
|
@ -165,10 +165,13 @@ class Status(Schema):
|
|||
cls,
|
||||
post: activities_models.Post,
|
||||
interactions: dict[str, set[str]] | None = None,
|
||||
bookmarks: set[str] | None = None,
|
||||
identity: users_models.Identity | None = None,
|
||||
) -> "Status":
|
||||
return cls(
|
||||
**post.to_mastodon_json(interactions=interactions, identity=identity)
|
||||
**post.to_mastodon_json(
|
||||
interactions=interactions, bookmarks=bookmarks, identity=identity
|
||||
)
|
||||
)
|
||||
|
||||
@classmethod
|
||||
|
@ -180,8 +183,11 @@ class Status(Schema):
|
|||
interactions = activities_models.PostInteraction.get_post_interactions(
|
||||
posts, identity
|
||||
)
|
||||
bookmarks = users_models.Bookmark.for_identity(identity, posts)
|
||||
return [
|
||||
cls.from_post(post, interactions=interactions, identity=identity)
|
||||
cls.from_post(
|
||||
post, interactions=interactions, bookmarks=bookmarks, identity=identity
|
||||
)
|
||||
for post in posts
|
||||
]
|
||||
|
||||
|
@ -190,11 +196,12 @@ class Status(Schema):
|
|||
cls,
|
||||
timeline_event: activities_models.TimelineEvent,
|
||||
interactions: dict[str, set[str]] | None = None,
|
||||
bookmarks: set[str] | None = None,
|
||||
identity: users_models.Identity | None = None,
|
||||
) -> "Status":
|
||||
return cls(
|
||||
**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(
|
||||
events, identity
|
||||
)
|
||||
bookmarks = users_models.Bookmark.for_identity(
|
||||
identity, events, "subject_post_id"
|
||||
)
|
||||
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
|
||||
]
|
||||
|
||||
|
|
|
@ -91,6 +91,8 @@ urlpatterns = [
|
|||
path("v1/statuses/<id>/favourited_by", statuses.favourited_by),
|
||||
path("v1/statuses/<id>/reblog", statuses.reblog_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
|
||||
path("v1/followed_tags", tags.followed_tags),
|
||||
# Timelines
|
||||
|
|
|
@ -1,8 +1,11 @@
|
|||
from django.http import HttpRequest
|
||||
from hatchway import api_view
|
||||
|
||||
from activities.models import Post
|
||||
from activities.services import TimelineService
|
||||
from api import schemas
|
||||
from api.decorators import scope_required
|
||||
from api.pagination import MastodonPaginator, PaginatingApiResponse, PaginationResult
|
||||
|
||||
|
||||
@scope_required("read:bookmarks")
|
||||
|
@ -14,5 +17,17 @@ def bookmarks(
|
|||
min_id: str | None = None,
|
||||
limit: int = 20,
|
||||
) -> list[schemas.Status]:
|
||||
# We don't implement this yet
|
||||
return []
|
||||
queryset = TimelineService(request.identity).bookmarks()
|
||||
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"],
|
||||
)
|
||||
|
|
|
@ -267,3 +267,25 @@ def unreblog_status(request, id: str) -> schemas.Status:
|
|||
return schemas.Status.from_post(
|
||||
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
|
||||
)
|
||||
|
|
|
@ -242,6 +242,10 @@ urlpatterns = [
|
|||
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>/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>/report/", report.SubmitReport.as_view()),
|
||||
path("@<handle>/posts/<int:post_id>/edit/", compose.Compose.as_view()),
|
||||
|
|
|
@ -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 %}
|
|
@ -82,6 +82,7 @@
|
|||
{% include "activities/_reply.html" %}
|
||||
{% include "activities/_like.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">
|
||||
<i class="fa-solid fa-bars"></i>
|
||||
</a>
|
||||
|
|
|
@ -8,6 +8,7 @@ from activities.admin import IdentityLocalFilter
|
|||
from users.models import (
|
||||
Announcement,
|
||||
Block,
|
||||
Bookmark,
|
||||
Domain,
|
||||
Follow,
|
||||
Identity,
|
||||
|
@ -221,3 +222,9 @@ class ReportAdmin(admin.ModelAdmin):
|
|||
class AnnouncementAdmin(admin.ModelAdmin):
|
||||
list_display = ["id", "published", "start", "end", "text"]
|
||||
autocomplete_fields = ["seen"]
|
||||
|
||||
|
||||
@admin.register(Bookmark)
|
||||
class BookmarkAdmin(admin.ModelAdmin):
|
||||
list_display = ["id", "identity", "post", "created"]
|
||||
raw_id_fields = ["identity", "post"]
|
||||
|
|
|
@ -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")},
|
||||
},
|
||||
),
|
||||
]
|
|
@ -1,5 +1,6 @@
|
|||
from .announcement import Announcement # noqa
|
||||
from .block import Block, BlockStates # noqa
|
||||
from .bookmark import Bookmark # noqa
|
||||
from .domain import Domain # noqa
|
||||
from .follow import Follow, FollowStates # noqa
|
||||
from .identity import Identity, IdentityStates # noqa
|
||||
|
|
|
@ -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))
|
Loading…
Reference in New Issue