reply; emoji

This commit is contained in:
Your Name 2023-08-15 15:46:11 -04:00 committed by Henri Dickson
parent bd3f7f4c94
commit 5df23582af
19 changed files with 446 additions and 19 deletions

View file

@ -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/"))

View file

@ -46,15 +46,11 @@
data-uuid="{{ comment.item.uuid }}"><i class="fa-regular fa-circle-play"></i></a>
</span>
{% endif %}
<span>
{% liked_piece comment as liked %}
{% include 'like_stats.html' with liked=liked piece=comment %}
</span>
<span>
<a target="_blank"
rel="noopener"
{% if comment.shared_link %} href="{{ comment.shared_link }}" title="打开联邦宇宙分享链接" {% else %} class="disabled" {% endif %}><i class="fa-solid {% if comment.visibility > 0 %} fa-lock {% else %} fa-globe {% endif %}"></i></a>
</span>
{% 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 %}
</span>
<span>
{% if comment.rating_grade %}{{ comment.rating_grade|rating_star }}{% endif %}
@ -70,6 +66,7 @@
</span>
{% if comment.item != item %}<a href="{{ comment.item_url }}">{{ comment.item.title }}</a>{% endif %}
<div>{{ comment.html|safe }}</div>
{% if comment.post_id %}<div id="replies_{{ comment.post_id }}"></div>{% endif %}
</section>
{% else %}
<a hx-get="{% url 'catalog:comments' comment.item.url_path comment.item.uuid %}?last={{ comment.created_time|date:'Y-m-d H:i:s.uO'|urlencode }}"

View file

@ -0,0 +1,29 @@
section.replies {
border-left: 1px solid var(--pico-muted-color);
margin-left: var(--pico-spacing);
padding-left: var(--pico-spacing);
margin-bottom: 0 !important;
>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;
}
}
}

View file

@ -18,3 +18,4 @@
@import '_common.scss';
@import '_login.scss';
@import '_form.scss';
@import '_post.scss';

View file

@ -62,6 +62,16 @@
</a>
</span>
{% endif %}
{% if identity.user.mastodon_account %}
<span>
<a href="{{ identity.user.mastodon_account.url }}"
target="_blank"
rel="noopener"
title="@{{ identity.user.mastodon_acct }}">
<i class="fa-brands fa-mastodon"></i>
</a>
</span>
{% endif %}
{% elif request.user.is_authenticated %}
{% include 'users/profile_actions.html' %}
{% endif %}

View file

@ -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]

View file

@ -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(

View file

@ -0,0 +1,15 @@
{% load user_actions %}
<span hx-target="this" hx-swap="outerHTML">
{% liked_post post as liked %}
{% if liked %}
<a class="activated" hx-post="{% url 'journal:post_unlike' post.pk %}">
<i class="fa-solid fa-thumbs-up }}"></i>
<span>{{ post.stats.likes }}</span>
</a>
{% else %}
<a hx-post="{% url 'journal:post_like' post.pk %}">
<i class="fa-regular fa-thumbs-up }}"></i>
{% if post.stats.likes %}<span>{{ post.stats.likes }}</span>{% endif %}
</a>
{% endif %}
</span>

View file

@ -0,0 +1,18 @@
<span>
<a target="_blank"
rel="noopener"
href="{{ post.object_uri }}"
class="disabled">
{% if post.visibility == 1 %}
<i class="fa-solid fa-lock-open"></i>
{% elif post.visibility == 2 %}
<i class="fa-solid fa-lock"></i>
{% elif post.visibility == 3 %}
<i class="fa-solid fa-at"></i>
{% elif post.visibility == 4 %}
<i class="fa-solid fa-igloo"></i>
{% else %}
<i class="fa-solid fa-globe"></i>
{% endif %}
</a>
</span>

View file

@ -0,0 +1,9 @@
<span>
<a hx-get="{% url 'journal:post_replies' post.pk %}"
hx-swap="outerHTML"
hx-trigger="click once"
hx-target="#replies_{{ post.pk }}">
<i class="fa-solid fa-reply"></i>
{% if post.stats.replies %}<span>{{ post.stats.replies }}</span>{% endif %}
</a>
</span>

View file

@ -0,0 +1,91 @@
<section class="replies" hx-target="this" hx-swap="outerHTML">
{% for post in replies %}
<div>
<span class="action">
{% include "action_reply_post.html" %}
{% include "action_like_post.html" %}
{% include "action_open_post.html" %}
</span>
<span>
<a href="{{ post.author.url }}"
class="nickname"
title="@{{ post.author.handle }}">{{ post.author.name|default:post.author.username }}</a>
</span>
<span class="action inline">
<span class="timestamp">
{% if post.edited %}
{{ post.edited | date }}
<i class="fa-solid fa-pencil"></i>
{% elif post.published %}
{{ post.published | date }}
{% else %}
{{ post.created | date }}
{% endif %}
</span>
</span>
{% if post.summary %}
<details>
<summary>{{ post.summary }}</summary>
{{ post.safe_content_local }}
</details>
{% else %}
{{ post.safe_content_local }}
{% endif %}
<div id="replies_{{ post.pk }}"></div>
</div>
{% empty %}
<div class="empty">暂无回应</div>
{% endfor %}
<form class="reply"
role="group"
hx-post="{% url 'journal:post_reply' post.pk %}"
hx-trigger="submit once">
<input name="content" type="text" placeholder="Type your reply" />
<details class="dropdown">
<summary>
<i class="fa-solid fa-users-gear"></i>
</summary>
<ul>
<li>
<label>
<input type="radio"
name="visibility"
value="0"
{% if post.visibility == 0 %}checked{% endif %} />
<i class="fa-solid fa-globe"></i>
</label>
</li>
<li>
<label>
<input type="radio"
name="visibility"
value="1"
{% if post.visibility == 1 %}checked{% endif %} />
<i class="fa-solid fa-lock-open"></i>
</label>
</li>
<li>
<label>
<input type="radio"
name="visibility"
value="2"
{% if post.visibility == 2 %}checked{% endif %} />
<i class="fa-solid fa-lock"></i>
</label>
</li>
<li>
<label>
<input type="radio"
name="visibility"
value="3"
{% if post.visibility == 3 %}checked{% endif %} />
<i class="fa-solid fa-at"></i>
</label>
</li>
</ul>
</details>
<button class="secondary">
<i class="fa-solid fa-reply"></i>
</button>
</form>
</section>

View file

@ -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)
)

View file

@ -23,6 +23,11 @@ urlpatterns = [
path("unlike/<str:piece_uuid>", unlike, name="unlike"),
path("mark/<str:item_uuid>", mark, name="mark"),
path("comment/<str:item_uuid>", comment, name="comment"),
path("piece/<str:piece_uuid>/replies", piece_replies, name="piece_replies"),
path("post/<int:post_id>/replies", post_replies, name="post_replies"),
path("post/<int:post_id>/reply", post_reply, name="post_reply"),
path("post/<int:post_id>/like", post_like, name="post_like"),
path("post/<int:post_id>/unlike", post_unlike, name="post_unlike"),
path("mark_log/<str:item_uuid>/<int:log_id>", mark_log, name="mark_log"),
path(
"add_to_collection/<str:item_uuid>", add_to_collection, name="add_to_collection"

View file

@ -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

64
journal/views/post.py Normal file
View file

@ -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)})

View file

@ -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}/",

View file

@ -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

View file

@ -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}/",

View file

@ -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):