From 5df23582af6cc595e6bd8b1accd2804a275e190d Mon Sep 17 00:00:00 2001 From: Your Name Date: Tue, 15 Aug 2023 15:46:11 -0400 Subject: [PATCH] reply; emoji --- boofilsic/settings.py | 1 + catalog/templates/_item_comments.html | 15 ++- common/static/scss/_post.scss | 29 ++++++ common/static/scss/neodb.scss | 1 + common/templates/_sidebar.html | 10 ++ journal/models/common.py | 15 +++ journal/models/mark.py | 5 +- journal/templates/action_like_post.html | 15 +++ journal/templates/action_open_post.html | 18 ++++ journal/templates/action_reply_post.html | 9 ++ journal/templates/replies.html | 91 ++++++++++++++++++ journal/templatetags/user_actions.py | 12 ++- journal/urls.py | 5 + journal/views/__init__.py | 1 + journal/views/post.py | 64 +++++++++++++ takahe/models.py | 114 ++++++++++++++++++++++- takahe/utils.py | 49 ++++++++++ users/migrations/0013_init_identity.py | 4 +- users/models/apidentity.py | 7 +- 19 files changed, 446 insertions(+), 19 deletions(-) create mode 100644 common/static/scss/_post.scss create mode 100644 journal/templates/action_like_post.html create mode 100644 journal/templates/action_open_post.html create mode 100644 journal/templates/action_reply_post.html create mode 100644 journal/templates/replies.html create mode 100644 journal/views/post.py diff --git a/boofilsic/settings.py b/boofilsic/settings.py index e0cc4491..3f836c1f 100644 --- a/boofilsic/settings.py +++ b/boofilsic/settings.py @@ -219,6 +219,7 @@ SILENCED_SYSTEM_CHECKS = [ "fields.W344", # Required by takahe: identical table name in different database ] +TAKAHE_MEDIA_PREFIX = "/media/" MEDIA_URL = "/m/" MEDIA_ROOT = os.environ.get("NEODB_MEDIA_ROOT", os.path.join(BASE_DIR, "media/")) diff --git a/catalog/templates/_item_comments.html b/catalog/templates/_item_comments.html index 2a620b58..706e3a0e 100644 --- a/catalog/templates/_item_comments.html +++ b/catalog/templates/_item_comments.html @@ -46,15 +46,11 @@ data-uuid="{{ comment.item.uuid }}"> {% endif %} - - {% liked_piece comment as liked %} - {% include 'like_stats.html' with liked=liked piece=comment %} - - - - + {% if comment.post %} + {% include "action_reply_post.html" with post=comment.post %} + {% include "action_like_post.html" with post=comment.post %} + {% include "action_open_post.html" with post=comment.post %} + {% endif %} {% if comment.rating_grade %}{{ comment.rating_grade|rating_star }}{% endif %} @@ -70,6 +66,7 @@ {% if comment.item != item %}{{ comment.item.title }}{% endif %}
{{ comment.html|safe }}
+ {% if comment.post_id %}
{% endif %} {% else %} div { + margin-bottom: calc(var(--pico-spacing)); + } + p { + margin-bottom: 0; + } + details { + summary { + text-decoration: underline; + } + } + form { + margin-bottom: 0; + select { + width: min-content; + } + button{ + height: calc(1rem * var(--pico-line-height) + var(--pico-form-element-spacing-vertical) * 2 + var(--pico-border-width) * 2) + } + details.dropdown > summary::after { + display: none; + } + } +} diff --git a/common/static/scss/neodb.scss b/common/static/scss/neodb.scss index 92752b27..bde590ef 100644 --- a/common/static/scss/neodb.scss +++ b/common/static/scss/neodb.scss @@ -18,3 +18,4 @@ @import '_common.scss'; @import '_login.scss'; @import '_form.scss'; +@import '_post.scss'; diff --git a/common/templates/_sidebar.html b/common/templates/_sidebar.html index e482eead..8c5e6075 100644 --- a/common/templates/_sidebar.html +++ b/common/templates/_sidebar.html @@ -62,6 +62,16 @@ {% endif %} + {% if identity.user.mastodon_account %} + + + + + + {% endif %} {% elif request.user.is_authenticated %} {% include 'users/profile_actions.html' %} {% endif %} diff --git a/journal/models/common.py b/journal/models/common.py index 4b5b35ff..3c694394 100644 --- a/journal/models/common.py +++ b/journal/models/common.py @@ -140,6 +140,10 @@ class Piece(PolymorphicModel, UserOwnedObjectMixin): def api_url(self): return f"/api/{self.url}" if self.url_path else None + @property + def post(self): + return Takahe.get_post(self.post_id) if self.post_id else None + @property def shared_link(self): return Takahe.get_post_url(self.post_id) if self.post_id else None @@ -153,6 +157,17 @@ class Piece(PolymorphicModel, UserOwnedObjectMixin): def is_liked_by(self, user): return self.post_id and Takahe.post_liked_by(self.post_id, user) + @property + def reply_count(self): + return ( + Takahe.get_post_stats(self.post_id).get("replies", 0) if self.post_id else 0 + ) + + def get_replies(self, viewing_identity): + return Takahe.get_post_replies( + self.post_id, viewing_identity.pk if viewing_identity else None + ) + @classmethod def get_by_url(cls, url_or_b62): b62 = url_or_b62.strip().split("/")[-1] diff --git a/journal/models/mark.py b/journal/models/mark.py index 1961366c..684fe836 100644 --- a/journal/models/mark.py +++ b/journal/models/mark.py @@ -133,12 +133,13 @@ class Mark: shelf_type != self.shelf_type or comment_text != self.comment_text or rating_grade != self.rating_grade + or visibility != self.visibility ) - if shelf_type is None: + if shelf_type is None or visibility != self.visibility: Takahe.delete_mark(self) if created_time and created_time >= timezone.now(): created_time = None - post_as_new = shelf_type != self.shelf_type + post_as_new = shelf_type != self.shelf_type or visibility != self.visibility original_visibility = self.visibility if shelf_type != self.shelf_type or visibility != original_visibility: self.shelfmember = self.owner.shelf_manager.move_item( diff --git a/journal/templates/action_like_post.html b/journal/templates/action_like_post.html new file mode 100644 index 00000000..49089eaa --- /dev/null +++ b/journal/templates/action_like_post.html @@ -0,0 +1,15 @@ +{% load user_actions %} + + {% liked_post post as liked %} + {% if liked %} + + + {{ post.stats.likes }} + + {% else %} + + + {% if post.stats.likes %}{{ post.stats.likes }}{% endif %} + + {% endif %} + diff --git a/journal/templates/action_open_post.html b/journal/templates/action_open_post.html new file mode 100644 index 00000000..eb89aea8 --- /dev/null +++ b/journal/templates/action_open_post.html @@ -0,0 +1,18 @@ + + + {% if post.visibility == 1 %} + + {% elif post.visibility == 2 %} + + {% elif post.visibility == 3 %} + + {% elif post.visibility == 4 %} + + {% else %} + + {% endif %} + + diff --git a/journal/templates/action_reply_post.html b/journal/templates/action_reply_post.html new file mode 100644 index 00000000..04f430d2 --- /dev/null +++ b/journal/templates/action_reply_post.html @@ -0,0 +1,9 @@ + + + + {% if post.stats.replies %}{{ post.stats.replies }}{% endif %} + + diff --git a/journal/templates/replies.html b/journal/templates/replies.html new file mode 100644 index 00000000..bbcedbaf --- /dev/null +++ b/journal/templates/replies.html @@ -0,0 +1,91 @@ +
+ {% for post in replies %} +
+ + {% include "action_reply_post.html" %} + {% include "action_like_post.html" %} + {% include "action_open_post.html" %} + + + {{ post.author.name|default:post.author.username }} + + + + {% if post.edited %} + {{ post.edited | date }} + + {% elif post.published %} + {{ post.published | date }} + {% else %} + {{ post.created | date }} + {% endif %} + + + {% if post.summary %} +
+ {{ post.summary }} + {{ post.safe_content_local }} +
+ {% else %} + {{ post.safe_content_local }} + {% endif %} +
+
+ {% empty %} +
暂无回应
+ {% endfor %} +
+ + + +
+
diff --git a/journal/templatetags/user_actions.py b/journal/templatetags/user_actions.py index d1a68b5d..528a553b 100644 --- a/journal/templatetags/user_actions.py +++ b/journal/templatetags/user_actions.py @@ -38,5 +38,15 @@ def liked_piece(context, piece): user and user.is_authenticated and piece.post_id - and Takahe.get_user_interaction(piece.post_id, user, "like") + and Takahe.get_user_interaction(piece.post_id, user.identity.pk, "like") + ) + + +@register.simple_tag(takes_context=True) +def liked_post(context, post): + user = context["request"].user + return ( + user + and user.is_authenticated + and Takahe.post_liked_by(post.pk, user.identity.pk) ) diff --git a/journal/urls.py b/journal/urls.py index a220ff15..215151c2 100644 --- a/journal/urls.py +++ b/journal/urls.py @@ -23,6 +23,11 @@ urlpatterns = [ path("unlike/", unlike, name="unlike"), path("mark/", mark, name="mark"), path("comment/", comment, name="comment"), + path("piece//replies", piece_replies, name="piece_replies"), + path("post//replies", post_replies, name="post_replies"), + path("post//reply", post_reply, name="post_reply"), + path("post//like", post_like, name="post_like"), + path("post//unlike", post_unlike, name="post_unlike"), path("mark_log//", mark_log, name="mark_log"), path( "add_to_collection/", add_to_collection, name="add_to_collection" diff --git a/journal/views/__init__.py b/journal/views/__init__.py index 759efc54..aa58787f 100644 --- a/journal/views/__init__.py +++ b/journal/views/__init__.py @@ -25,6 +25,7 @@ from .mark import ( user_mark_list, wish, ) +from .post import piece_replies, post_like, post_replies, post_reply, post_unlike from .profile import profile, user_calendar_data from .review import ReviewFeed, review_edit, review_retrieve, user_review_list from .tag import user_tag_edit, user_tag_list, user_tag_member_list diff --git a/journal/views/post.py b/journal/views/post.py new file mode 100644 index 00000000..cb9bd0eb --- /dev/null +++ b/journal/views/post.py @@ -0,0 +1,64 @@ +from django.contrib.auth.decorators import login_required +from django.core.exceptions import BadRequest, ObjectDoesNotExist, PermissionDenied +from django.shortcuts import get_object_or_404, redirect, render +from django.urls import reverse +from django.utils.translation import gettext_lazy as _ +from httpx import request + +from catalog.models import * +from common.utils import ( + AuthedHttpRequest, + PageLinksGenerator, + get_uuid_or_404, + target_identity_required, +) +from takahe.utils import Takahe + +from ..forms import * +from ..models import * + + +@login_required +def piece_replies(request: AuthedHttpRequest, piece_uuid: str): + piece = get_object_or_404(Piece, uid=get_uuid_or_404(piece_uuid)) + if not piece.is_visible_to(request.user): + raise PermissionDenied() + replies = piece.get_replies(request.user.identity) + return render(request, "replies.html", {"post": piece.post, "replies": replies}) + + +@login_required +def post_replies(request: AuthedHttpRequest, post_id: int): + replies = Takahe.get_post_replies(post_id, request.user.identity.pk) + return render( + request, "replies.html", {"post": Takahe.get_post(post_id), "replies": replies} + ) + + +@login_required +def post_reply(request: AuthedHttpRequest, post_id: int): + content = request.POST.get("content", "").strip() + visibility = Takahe.Visibilities(int(request.POST.get("visibility", -1))) + if request.method != "POST" or not content: + raise BadRequest() + Takahe.reply_post(post_id, request.user.identity.pk, content, visibility) + replies = Takahe.get_post_replies(post_id, request.user.identity.pk) + return render( + request, "replies.html", {"post": Takahe.get_post(post_id), "replies": replies} + ) + + +@login_required +def post_like(request: AuthedHttpRequest, post_id: int): + if request.method != "POST": + raise BadRequest() + Takahe.like_post(post_id, request.user.identity.pk) + return render(request, "action_like_post.html", {"post": Takahe.get_post(post_id)}) + + +@login_required +def post_unlike(request: AuthedHttpRequest, post_id: int): + if request.method != "POST": + raise BadRequest() + Takahe.unlike_post(post_id, request.user.identity.pk) + return render(request, "action_like_post.html", {"post": Takahe.get_post(post_id)}) diff --git a/takahe/models.py b/takahe/models.py index 5fb05f5f..c5797595 100644 --- a/takahe/models.py +++ b/takahe/models.py @@ -22,7 +22,7 @@ from django.utils.safestring import mark_safe from loguru import logger from lxml import etree -from .html import FediverseHtmlParser +from .html import ContentRenderer, FediverseHtmlParser from .uris import * if TYPE_CHECKING: @@ -419,6 +419,14 @@ class Identity(models.Model): return f"{self.username}@{self.domain_id}" return f"{self.username}@(unknown server)" + @property + def url(self): + return ( + f"/users/{self.username}/" + if self.local + else f"/users/@{self.username}@{self.domain_id}/" + ) + @property def user_pk(self): user = self.users.first() @@ -630,6 +638,101 @@ class Follow(models.Model): return f"#{self.id}: {self.source} → {self.target}" +class PostQuerySet(models.QuerySet): + def not_hidden(self): + query = self.exclude(state__in=["deleted", "deleted_fanned_out"]) + return query + + def public(self, include_replies: bool = False): + query = self.filter( + visibility__in=[ + Post.Visibilities.public, + Post.Visibilities.local_only, + ], + ) + if not include_replies: + return query.filter(in_reply_to__isnull=True) + return query + + def local_public(self, include_replies: bool = False): + query = self.filter( + visibility__in=[ + Post.Visibilities.public, + Post.Visibilities.local_only, + ], + local=True, + ) + if not include_replies: + return query.filter(in_reply_to__isnull=True) + return query + + def unlisted(self, include_replies: bool = False): + query = self.filter( + visibility__in=[ + Post.Visibilities.public, + Post.Visibilities.local_only, + Post.Visibilities.unlisted, + ], + ) + if not include_replies: + return query.filter(in_reply_to__isnull=True) + return query + + def visible_to(self, identity: Identity | None, include_replies: bool = False): + if identity is None: + return self.unlisted(include_replies=include_replies) + 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( + mentions=identity, + ) + | models.Q(author=identity) + ).distinct() + if not include_replies: + return query.filter(in_reply_to__isnull=True) + return query + + # def tagged_with(self, hashtag: str | Hashtag): + # if isinstance(hashtag, str): + # tag_q = models.Q(hashtags__contains=hashtag) + # else: + # tag_q = models.Q(hashtags__contains=hashtag.hashtag) + # if hashtag.aliases: + # for alias in hashtag.aliases: + # tag_q |= models.Q(hashtags__contains=alias) + # return self.filter(tag_q) + + +class PostManager(models.Manager): + def get_queryset(self): + return PostQuerySet(self.model, using=self._db) + + def not_hidden(self): + return self.get_queryset().not_hidden() + + def public(self, include_replies: bool = False): + return self.get_queryset().public(include_replies=include_replies) + + def local_public(self, include_replies: bool = False): + return self.get_queryset().local_public(include_replies=include_replies) + + def unlisted(self, include_replies: bool = False): + return self.get_queryset().unlisted(include_replies=include_replies) + + # def tagged_with(self, hashtag: str | Hashtag): + # return self.get_queryset().tagged_with(hashtag=hashtag) + + class Post(models.Model): """ A post (status, toot) that is either local or remote. @@ -739,6 +842,7 @@ class Post(models.Model): created = models.DateTimeField(auto_now_add=True) updated = models.DateTimeField(auto_now=True) + objects = PostManager() class Meta: # managed = False @@ -810,7 +914,6 @@ class Post(models.Model): with transaction.atomic(): # Find mentions in this post mentions = cls.mentions_from_content(content, author) - # mentions = set() if reply_to: mentions.add(reply_to.author) # Maintain local-only for replies @@ -955,6 +1058,10 @@ class Post(models.Model): if save: self.save() + @property + def safe_content_local(self): + return ContentRenderer(local=True).render_post(self.content, self) + class EmojiQuerySet(models.QuerySet): def usable(self, domain: Domain | None = None): @@ -1070,7 +1177,8 @@ class Emoji(models.Model): def full_url(self, always_show=False) -> RelativeAbsoluteUrl: if self.is_usable or always_show: if self.file: - return AutoAbsoluteUrl(self.file.url) + return AutoAbsoluteUrl(settings.TAKAHE_MEDIA_PREFIX + self.file.name) + # return AutoAbsoluteUrl(self.file.url) elif self.remote_url: return ProxyAbsoluteUrl( f"/proxy/emoji/{self.pk}/", diff --git a/takahe/utils.py b/takahe/utils.py index 6ec55ca1..82e128f7 100644 --- a/takahe/utils.py +++ b/takahe/utils.py @@ -342,6 +342,7 @@ class Takahe: content: str, visibility: Visibilities, data: dict | None = None, + reply_to_pk: int | None = None, post_pk: int | None = None, post_time: datetime.datetime | None = None, ) -> int | None: @@ -351,6 +352,13 @@ class Takahe: if post_pk else None ) + if post_pk and not post: + raise ValueError(f"Cannot find post to edit: {post_pk}") + reply_to_post = ( + Post.objects.filter(pk=reply_to_pk).first() if reply_to_pk else None + ) + if reply_to_pk and not reply_to_post: + raise ValueError(f"Cannot find post to reply: {reply_to_pk}") if post: post.edit_local( pre_conetent, content, visibility=visibility, type_data=data @@ -363,9 +371,14 @@ class Takahe: visibility=visibility, type_data=data, published=post_time, + reply_to=reply_to_post, ) return post.pk if post else None + @staticmethod + def get_post(post_pk: int) -> str | None: + return Post.objects.filter(pk=post_pk).first() + @staticmethod def get_post_url(post_pk: int) -> str | None: post = Post.objects.filter(pk=post_pk).first() if post_pk else None @@ -465,6 +478,12 @@ class Takahe: interaction.save() post.calculate_stats() + @staticmethod + def reply_post( + post_pk: int, identity_pk: int, content: str, visibility: Visibilities + ): + return Takahe.post(identity_pk, "", content, visibility, reply_to_pk=post_pk) + @staticmethod def like_post(post_pk: int, identity_pk: int): return Takahe.interact_post(post_pk, identity_pk, "like") @@ -497,3 +516,33 @@ class Takahe: logger.warning(f"Cannot find post {post_pk}") return {} return post.stats or {} + + @staticmethod + def get_post_replies(post_pk: int, identity_pk: int | None): + node = Post.objects.filter(pk=post_pk).first() + if not node: + return Post.objects.none() + identity = ( + Identity.objects.filter(pk=identity_pk).first() if identity_pk else None + ) + child_queryset = ( + Post.objects.not_hidden() + .prefetch_related( + # "attachments", + "mentions", + "emojis", + ) + .select_related( + "author", + "author__domain", + ) + .filter(in_reply_to=node.object_uri) + .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) + return child_queryset diff --git a/users/migrations/0013_init_identity.py b/users/migrations/0013_init_identity.py index 40136a90..9729032d 100644 --- a/users/migrations/0013_init_identity.py +++ b/users/migrations/0013_init_identity.py @@ -52,7 +52,9 @@ def init_identity(apps, schema_editor): domain_name=domain, deleted=None if user.is_active else user.updated, ) - takahe_user = TakaheUser.objects.create(pk=user.pk, email=handler) + takahe_user = TakaheUser.objects.create( + pk=user.pk, email=handler, admin=user.is_superuser + ) takahe_identity = TakaheIdentity.objects.create( pk=user.pk, actor_uri=f"https://{service_domain or domain}/@{username}@{domain}/", diff --git a/users/models/apidentity.py b/users/models/apidentity.py index 27cc1cd3..c82d14cc 100644 --- a/users/models/apidentity.py +++ b/users/models/apidentity.py @@ -66,17 +66,18 @@ class APIdentity(models.Model): def profile_uri(self): return self.takahe_identity.profile_uri - @property + @cached_property def display_name(self): return self.takahe_identity.name or self.username - @property + @cached_property def summary(self): return self.takahe_identity.summary or "" @property def avatar(self): - return self.takahe_identity.icon_uri or static("img/avatar.svg") # fixme + # return self.takahe_identity.icon_uri or static("img/avatar.svg") # fixme + return f"/proxy/identity_icon/{self.pk}/" @property def url(self):