diff --git a/activities/models/post.py b/activities/models/post.py index 127626e..e3e3da2 100644 --- a/activities/models/post.py +++ b/activities/models/post.py @@ -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 diff --git a/activities/models/timeline_event.py b/activities/models/timeline_event.py index e94dd8e..2f62d19 100644 --- a/activities/models/timeline_event.py +++ b/activities/models/timeline_event.py @@ -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( diff --git a/activities/services/timeline.py b/activities/services/timeline.py index 8450dbf..734fa74 100644 --- a/activities/services/timeline.py +++ b/activities/services/timeline.py @@ -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") + ) diff --git a/activities/views/posts.py b/activities/views/posts.py index fb8f099..d8e6fdc 100644 --- a/activities/views/posts.py +++ b/activities/views/posts.py @@ -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): """ diff --git a/activities/views/timelines.py b/activities/views/timelines.py index c748b76..ec75a5b 100644 --- a/activities/views/timelines.py +++ b/activities/views/timelines.py @@ -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 diff --git a/api/schemas.py b/api/schemas.py index 087bee1..fd5b89d 100644 --- a/api/schemas.py +++ b/api/schemas.py @@ -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 ] diff --git a/api/urls.py b/api/urls.py index 0b1553b..13cc9dc 100644 --- a/api/urls.py +++ b/api/urls.py @@ -91,6 +91,8 @@ urlpatterns = [ path("v1/statuses//favourited_by", statuses.favourited_by), path("v1/statuses//reblog", statuses.reblog_status), path("v1/statuses//unreblog", statuses.unreblog_status), + path("v1/statuses//bookmark", statuses.bookmark_status), + path("v1/statuses//unbookmark", statuses.unbookmark_status), # Tags path("v1/followed_tags", tags.followed_tags), # Timelines diff --git a/api/views/bookmarks.py b/api/views/bookmarks.py index ca382eb..b2079c7 100644 --- a/api/views/bookmarks.py +++ b/api/views/bookmarks.py @@ -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"], + ) diff --git a/api/views/statuses.py b/api/views/statuses.py index 5ce6e36..287d5e2 100644 --- a/api/views/statuses.py +++ b/api/views/statuses.py @@ -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 + ) diff --git a/takahe/urls.py b/takahe/urls.py index 9cb157d..a6e368b 100644 --- a/takahe/urls.py +++ b/takahe/urls.py @@ -242,6 +242,10 @@ urlpatterns = [ path("@/posts//unlike/", posts.Like.as_view(undo=True)), path("@/posts//boost/", posts.Boost.as_view()), path("@/posts//unboost/", posts.Boost.as_view(undo=True)), + path("@/posts//bookmark/", posts.Bookmark.as_view()), + path( + "@/posts//unbookmark/", posts.Bookmark.as_view(undo=True) + ), path("@/posts//delete/", posts.Delete.as_view()), path("@/posts//report/", report.SubmitReport.as_view()), path("@/posts//edit/", compose.Compose.as_view()), diff --git a/templates/activities/_bookmark.html b/templates/activities/_bookmark.html new file mode 100644 index 0000000..ab9731d --- /dev/null +++ b/templates/activities/_bookmark.html @@ -0,0 +1,9 @@ +{% if post.pk in bookmarks %} + + + +{% else %} + + + +{% endif %} diff --git a/templates/activities/_post.html b/templates/activities/_post.html index 1c1a6e6..a983004 100644 --- a/templates/activities/_post.html +++ b/templates/activities/_post.html @@ -82,6 +82,7 @@ {% include "activities/_reply.html" %} {% include "activities/_like.html" %} {% include "activities/_boost.html" %} + {% include "activities/_bookmark.html" %} diff --git a/users/admin.py b/users/admin.py index 7494c7a..76b1ec7 100644 --- a/users/admin.py +++ b/users/admin.py @@ -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"] diff --git a/users/migrations/0015_bookmark.py b/users/migrations/0015_bookmark.py new file mode 100644 index 0000000..2ff3789 --- /dev/null +++ b/users/migrations/0015_bookmark.py @@ -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")}, + }, + ), + ] diff --git a/users/models/__init__.py b/users/models/__init__.py index f564ed8..b1eed6c 100644 --- a/users/models/__init__.py +++ b/users/models/__init__.py @@ -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 diff --git a/users/models/bookmark.py b/users/models/bookmark.py new file mode 100644 index 0000000..66f7f7a --- /dev/null +++ b/users/models/bookmark.py @@ -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))