diff --git a/boofilsic/settings.py b/boofilsic/settings.py index e747f623..bb66dcfa 100644 --- a/boofilsic/settings.py +++ b/boofilsic/settings.py @@ -227,6 +227,7 @@ ENABLE_LOCAL_ONLY = env("NEODB_ENABLE_LOCAL_ONLY") # Timeout of requests to Mastodon, in seconds MASTODON_TIMEOUT = env("NEODB_LOGIN_MASTODON_TIMEOUT", default=5) # type: ignore +THREADS_TIMEOUT = 10 # Threads is really slow when publishing post TAKAHE_REMOTE_TIMEOUT = MASTODON_TIMEOUT NEODB_USER_AGENT = f"NeoDB/{NEODB_VERSION} (+{SITE_INFO.get('site_url', 'undefined')})" diff --git a/common/templates/_header.html b/common/templates/_header.html index 0eeebac8..304a6a3f 100644 --- a/common/templates/_header.html +++ b/common/templates/_header.html @@ -122,7 +122,12 @@ {% if messages %} {% endif %} diff --git a/journal/models/comment.py b/journal/models/comment.py index 37869e6c..38d8f818 100644 --- a/journal/models/comment.py +++ b/journal/models/comment.py @@ -123,7 +123,7 @@ class Comment(Content): ShelfManager.get_action_template(ShelfType.PROGRESS, self.item.category) ) - def to_mastodon_params(self): + def to_crosspost_params(self): spoiler_text, txt = render_spoiler_text(self.text, self.item) content = ( self.get_crosspost_template().format(item=self.item.display_title) diff --git a/journal/models/common.py b/journal/models/common.py index 70535bf1..71ebf50f 100644 --- a/journal/models/common.py +++ b/journal/models/common.py @@ -5,8 +5,11 @@ from datetime import datetime from functools import cached_property from typing import TYPE_CHECKING, Any, Self +import django_rq + # from deepmerge import always_merger from django.conf import settings +from django.core.exceptions import PermissionDenied from django.core.signing import b62_decode, b62_encode from django.db import models from django.db.models import CharField, Q @@ -14,10 +17,12 @@ from django.utils import timezone from django.utils.translation import gettext_lazy as _ from loguru import logger from polymorphic.models import PolymorphicModel +from user_messages import api as messages from catalog.common.models import Item, ItemCategory from catalog.models import item_categories, item_content_types from takahe.utils import Takahe +from users.middlewares import activate_language_for_user from users.models import APIdentity, User from .mixins import UserOwnedObjectMixin @@ -90,36 +95,10 @@ def q_item_in_category(item_category: ItemCategory): return Q(item__polymorphic_ctype__in=contenttype_ids) -# class ImportStatus(Enum): -# QUEUED = 0 -# PROCESSING = 1 -# FINISHED = 2 - - -# class ImportSession(models.Model): -# owner = models.ForeignKey(APIdentity, on_delete=models.CASCADE) -# status = models.PositiveSmallIntegerField(default=ImportStatus.QUEUED) -# importer = models.CharField(max_length=50) -# file = models.CharField() -# default_visibility = models.PositiveSmallIntegerField() -# total = models.PositiveIntegerField() -# processed = models.PositiveIntegerField() -# skipped = models.PositiveIntegerField() -# imported = models.PositiveIntegerField() -# failed = models.PositiveIntegerField() -# logs = models.JSONField(default=list) -# created_time = models.DateTimeField(auto_now_add=True) -# edited_time = models.DateTimeField(auto_now=True) - -# class Meta: -# indexes = [ -# models.Index(fields=["owner", "importer", "created_time"]), -# ] - - class Piece(PolymorphicModel, UserOwnedObjectMixin): if TYPE_CHECKING: likes: models.QuerySet["Like"] + metadata: models.JSONField[Any, Any] url_path = "p" # subclass must specify this uid = models.UUIDField(default=uuid.uuid4, editable=False, db_index=True) local = models.BooleanField(default=True) @@ -131,33 +110,16 @@ class Piece(PolymorphicModel, UserOwnedObjectMixin): def classname(self) -> str: return self.__class__.__name__.lower() - def get_mastodon_crosspost_url(self): - return ( - (self.metadata or {}).get("shared_link") - if hasattr(self, "metadata") - else None - ) - - def set_mastodon_crosspost_url(self, url: str | None): - if not hasattr(self, "metadata"): - logger.warning("metadata field not found") - return - metadata = self.metadata or {} - if metadata.get("shared_link", None) == url: - return - if not url: - metadata.pop("shared_link", None) - else: - metadata["shared_link"] = url - self.metadata = metadata - self.save(update_fields=["metadata"]) - def delete(self, *args, **kwargs): if self.local: Takahe.delete_posts(self.all_post_ids) - toot_url = self.get_mastodon_crosspost_url() - if toot_url and self.owner.user.mastodon: - self.owner.user.mastodon.delete_later(toot_url) + toot_id = ( + (self.metadata or {}).get("mastodon_id") + if hasattr(self, "metadata") + else None + ) + if toot_id and self.owner.user.mastodon: + self.owner.user.mastodon.delete_post_later(toot_id) return super().delete(*args, **kwargs) @property @@ -265,7 +227,7 @@ class Piece(PolymorphicModel, UserOwnedObjectMixin): return {} @abstractmethod - def to_mastodon_params(self) -> dict[str, Any]: + def to_crosspost_params(self) -> dict[str, Any]: return {} @classmethod @@ -314,74 +276,120 @@ class Piece(PolymorphicModel, UserOwnedObjectMixin): # subclass may have to add additional code to update type_data in local post return p - def sync_to_mastodon(self, delete_existing=False): - user = self.owner.user - if not user.mastodon: + def get_crosspost_params(self): + d = { + "visibility": self.visibility, + "update_id": (self.metadata if hasattr(self, "metadata") else {}).get( + "mastodon_id" + ), + } + d.update(self.to_crosspost_params()) + return d + + def sync_to_social_accounts(self, update_mode: int = 0): + """update_mode: 0 update if exists otherwise create; 1: delete if exists and create; 2: only create""" + django_rq.get_queue("mastodon").enqueue( + self._sync_to_social_accounts, update_mode + ) + + def _sync_to_social_accounts(self, update_mode: int): + activate_language_for_user(self.owner.user) + params = self.get_crosspost_params() + metadata = self.metadata.copy() + self.sync_to_mastodon(params, update_mode) + self.sync_to_threads(params, update_mode) + if self.metadata != metadata: + self.save(update_fields=["metadata"]) + + def sync_to_threads(self, params, update_mode): + # skip non-public post as Threads does not support it + # update_mode will be ignored as update/delete are not supported either + threads = self.owner.user.threads + if params["visibility"] != 0 or not threads: return - if user.preference.mastodon_repost_mode == 1: - if delete_existing: - self.delete_mastodon_repost() - return self.crosspost_to_mastodon() + try: + r = threads.post(**params) + except Exception: + logger.warning(f"{self} post to {threads} failed") + messages.error(threads.user, _("A recent post was not posted to Threads.")) + return False + self.metadata.update({"threads_" + k: v for k, v in r.items()}) + return True + + def sync_to_mastodon(self, params, update_mode): + mastodon = self.owner.user.mastodon + if not mastodon: + return + if self.owner.user.preference.mastodon_repost_mode == 1: + if update_mode == 1: + toot_id = self.metadata.pop("mastodon_id", None) + if toot_id: + self.metadata.pop("mastodon_url", None) + mastodon.delete(toot_id) + elif update_mode == 1: + params.pop("update_id", None) + return self.crosspost_to_mastodon(params) elif self.latest_post: - if user.mastodon: - user.mastodon.boost_later(self.latest_post.url) + mastodon.boost(self.latest_post.url) else: logger.warning("No post found for piece") - def delete_mastodon_repost(self): - toot_url = self.get_mastodon_crosspost_url() - if toot_url: - self.set_mastodon_crosspost_url(None) - if self.owner.user.mastodon: - self.owner.user.mastodon.delete_later(toot_url) + def crosspost_to_mastodon(self, params): + mastodon = self.owner.user.mastodon + if not mastodon: + return False + r = mastodon.post(**params) + try: + pass + except PermissionDenied: + messages.error( + mastodon.user, + _("A recent post was not posted to Mastodon, please re-authorize."), + meta={"url": mastodon.get_reauthorize_url()}, + ) + return False + except Exception: + logger.warning(f"{self} post to {mastodon} failed") + messages.error( + mastodon.user, _("A recent post was not posted to Mastodon.") + ) + return False + self.metadata.update({"mastodon_" + k: v for k, v in r.items()}) + return True - def crosspost_to_mastodon(self): - user = self.owner.user - if not user or not user.mastodon: - return False, -1 - d = { - "visibility": self.visibility, - "update_toot_url": self.get_mastodon_crosspost_url(), + def get_ap_data(self): + return { + "object": { + "tag": ( + [self.item.ap_object_ref] # type:ignore + if hasattr(self, "item") + else [] + ), + "relatedWith": [self.ap_object], + } } - d.update(self.to_mastodon_params()) - response = user.mastodon.post(**d) - if response is not None and response.status_code in [200, 201]: - j = response.json() - if "url" in j: - metadata = {"shared_link": j["url"]} - if self.metadata != metadata: - self.metadata = metadata - self.save(update_fields=["metadata"]) - return True, 200 - else: - logger.warning(response) - return False, response.status_code if response is not None else -1 - def sync_to_timeline(self, delete_existing=False): + def sync_to_timeline(self, update_mode: int = 0): + """update_mode: 0 update if exists otherwise create; 1: delete if exists and create; 2: only create""" user = self.owner.user v = Takahe.visibility_n2t(self.visibility, user.preference.post_public_mode) existing_post = self.latest_post - if existing_post and existing_post.state in ["deleted", "deleted_fanned_out"]: - existing_post = None - elif existing_post and delete_existing: - Takahe.delete_posts([existing_post.pk]) - existing_post = None + if existing_post: + if ( + existing_post.state in ["deleted", "deleted_fanned_out"] + or update_mode == 2 + ): + existing_post = None + elif update_mode == 1: + Takahe.delete_posts([existing_post.pk]) + existing_post = None params = { "author_pk": self.owner.pk, "visibility": v, "post_pk": existing_post.pk if existing_post else None, "post_time": self.created_time, # type:ignore subclass must have this "edit_time": self.edited_time, # type:ignore subclass must have this - "data": { - "object": { - "tag": ( - [self.item.ap_object_ref] # type:ignore - if hasattr(self, "item") - else [] - ), - "relatedWith": [self.ap_object], - } - }, + "data": self.get_ap_data(), } params.update(self.to_post_params()) post = Takahe.post(**params) diff --git a/journal/models/mark.py b/journal/models/mark.py index 560a85b7..0b0729fe 100644 --- a/journal/models/mark.py +++ b/journal/models/mark.py @@ -18,33 +18,6 @@ from .review import Review from .shelf import Shelf, ShelfLogEntry, ShelfManager, ShelfMember, ShelfType -def share_mark(mark, post_as_new=False): - user = mark.owner.user - if not user or not user.mastodon: - return - stars = user.mastodon.rating_to_emoji(mark.rating_grade) - spoiler_text, txt = get_spoiler_text(mark.comment_text or "", mark.item) - content = f"{mark.get_action_for_feed()} {stars}\n{mark.item.absolute_url}\n{txt}{mark.tag_text}" - update_url = ( - None if post_as_new else (mark.shelfmember.metadata or {}).get("shared_link") - ) - response = user.mastodon.post( - content, - mark.visibility, - update_url, - spoiler_text, - ) - if response is not None and response.status_code in [200, 201]: - j = response.json() - if "url" in j: - mark.shelfmember.metadata = {"shared_link": j["url"]} - mark.shelfmember.save(update_fields=["metadata"]) - return True, 200 - else: - logger.warning(response) - return False, response.status_code if response is not None else -1 - - class Mark: """ Holding Mark for an item on an shelf, @@ -241,6 +214,7 @@ class Mark: visibility = self.visibility last_shelf_type = self.shelf_type last_visibility = self.visibility if last_shelf_type else None + update_mode = 0 if tags is not None: self.owner.tag_manager.tag_item(self.item, tags, visibility) if shelf_type is None: @@ -264,8 +238,7 @@ class Mark: self.shelfmember.visibility = visibility shelfmember_changed = True # retract most recent post about this status when visibility changed - if self.shelfmember.latest_post: - Takahe.delete_posts([self.shelfmember.latest_post.pk]) + update_mode = 1 if created_time and created_time != self.shelfmember.created_time: self.shelfmember.created_time = created_time log_entry.timestamp = created_time @@ -277,6 +250,8 @@ class Mark: if shelfmember_changed: self.shelfmember.save() else: + # ignore most recent post if exists and create new one + update_mode = 2 shelf = Shelf.objects.get(owner=self.owner, shelf_type=shelf_type) d = {"parent": shelf, "visibility": visibility, "position": 0} if metadata: @@ -305,28 +280,17 @@ class Mark: ) self.rating_grade = rating_grade # publish a new or updated ActivityPub post - user: User = self.owner.user - post_as_new = shelf_type != last_shelf_type or visibility != last_visibility - classic_crosspost = user.preference.mastodon_repost_mode == 1 - append = ( - f"@{user.mastodon.handle}\n" - if visibility > 0 - and share_to_mastodon - and not classic_crosspost - and user.mastodon - else "" - ) - post = Takahe.post_mark(self, post_as_new, append) - if post and self.item.category in (user.preference.auto_bookmark_cats or []): - if shelf_type == ShelfType.PROGRESS: - Takahe.bookmark(post.pk, self.owner.pk) - # async boost to mastodon - if post and share_to_mastodon: - if classic_crosspost: - share_mark(self, post_as_new) - elif user.mastodon: - user.mastodon.boost_later(post.url) - return True + post = self.shelfmember.sync_to_timeline(update_mode) + if share_to_mastodon: + self.shelfmember.sync_to_social_accounts(update_mode) + # auto add bookmark + if ( + post + and shelf_type == ShelfType.PROGRESS + and self.item.category + in (self.owner.user.preference.auto_bookmark_cats or []) + ): + Takahe.bookmark(post.pk, self.owner.pk) def delete(self, keep_tags=False): self.update(None, tags=None if keep_tags else []) diff --git a/journal/models/note.py b/journal/models/note.py index c86de923..f848e697 100644 --- a/journal/models/note.py +++ b/journal/models/note.py @@ -160,21 +160,21 @@ class Note(Content): # if local piece is created from a post, update post type_data and fanout p.sync_to_timeline() if owner.user.preference.mastodon_default_repost and owner.user.mastodon: - p.sync_to_mastodon() + p.sync_to_social_accounts() return p @cached_property def shelfmember(self) -> ShelfMember | None: return ShelfMember.objects.filter(item=self.item, owner=self.owner).first() - def to_mastodon_params(self): + def to_crosspost_params(self): footer = f"\n—\n《{self.item.display_title}》 {self.progress_display}\n{self.item.absolute_url}" params = { "spoiler_text": self.title, "content": self.content + footer, "sensitive": self.sensitive, - "reply_to_toot_url": ( - self.shelfmember.get_mastodon_crosspost_url() + "reply_to_id": ( + (self.shelfmember.metadata or {}).get("mastodon_id") if self.shelfmember else None ), diff --git a/journal/models/review.py b/journal/models/review.py index 75c777a3..a12e4171 100644 --- a/journal/models/review.py +++ b/journal/models/review.py @@ -86,7 +86,7 @@ class Review(Content): def get_crosspost_template(self): return _(ShelfManager.get_action_template("reviewed", self.item.category)) - def to_mastodon_params(self): + def to_crosspost_params(self): content = ( self.get_crosspost_template().format(item=self.item.display_title) + f"\n{self.title}\n{self.absolute_url} " @@ -153,7 +153,8 @@ class Review(Content): review, created = cls.objects.update_or_create( item=item, owner=owner, defaults=defaults ) - review.sync_to_timeline(delete_existing=delete_existing_post) + update_mode = 1 if delete_existing_post else 0 + review.sync_to_timeline(update_mode) if share_to_mastodon: - review.sync_to_mastodon(delete_existing=delete_existing_post) + review.sync_to_social_accounts(update_mode) return review diff --git a/journal/models/shelf.py b/journal/models/shelf.py index 2a496e7b..43fc1b56 100644 --- a/journal/models/shelf.py +++ b/journal/models/shelf.py @@ -2,11 +2,10 @@ from datetime import datetime from functools import cached_property from typing import TYPE_CHECKING +from django.conf import settings from django.db import connection, models from django.utils import timezone from django.utils.translation import gettext_lazy as _ -from django.utils.translation import pgettext_lazy -from django.utils.translation import pgettext_lazy as __ from loguru import logger from catalog.models import Item, ItemCategory @@ -15,9 +14,12 @@ from users.models import APIdentity from .common import q_item_in_category from .itemlist import List, ListMember +from .renderers import render_post_with_macro, render_rating if TYPE_CHECKING: + from .comment import Comment from .mark import Mark + from .rating import Rating class ShelfType(models.TextChoices): @@ -356,6 +358,71 @@ class ShelfMember(ListMember): p.link_post_id(post.id) return p + def get_crosspost_postfix(self): + tags = render_post_with_macro( + self.owner.user.preference.mastodon_append_tag, self.item + ) + return "\n" + tags if tags else "" + + def get_crosspost_template(self): + return _(ShelfManager.get_action_template(self.shelf_type, self.item.category)) + + def to_crosspost_params(self): + action = self.get_crosspost_template().format(item=self.item.display_title) + if self.sibling_comment: + spoiler, txt = Takahe.get_spoiler_text(self.sibling_comment.text, self.item) + else: + spoiler, txt = None, "" + stars = ( + self.owner.user.mastodon.rating_to_emoji(self.sibling_rating.grade) + if self.sibling_rating and self.owner.user.mastodon + else "" + ) + content = f"{action} {stars} \n{self.item.absolute_url}\n{txt}\n{self.get_crosspost_postfix()}" + params = {"content": content, "spoiler_text": spoiler} + return params + + def to_post_params(self): + item_link = f"{settings.SITE_INFO['site_url']}/~neodb~{self.item.url}" + action = self.get_crosspost_template().format( + item=f'{self.item.display_title}' + ) + if self.sibling_comment: + spoiler, txt = Takahe.get_spoiler_text(self.sibling_comment.text, self.item) + else: + spoiler, txt = None, "" + postfix = self.get_crosspost_postfix() + # add @user.mastodon.handle so that user can see it on Mastodon ? + # if self.visibility and self.owner.user.mastodon: + # postfix += f" @{self.owner.user.mastodon.handle}" + content = f"{render_rating(self.sibling_rating.grade, 1) if self.sibling_rating else ''} \n{txt}\n{postfix}" + return { + "prepend_content": action, + "content": content, + "summary": spoiler, + "sensitive": spoiler is not None, + } + + def get_ap_data(self): + data = super().get_ap_data() + if self.sibling_comment: + data["object"]["relatedWith"].append(self.sibling_comment.ap_object) + if self.sibling_rating: + data["object"]["relatedWith"].append(self.sibling_rating.ap_object) + return data + + @cached_property + def sibling_comment(self) -> "Comment | None": + from .comment import Comment + + return Comment.objects.filter(owner=self.owner, item=self.item).first() + + @cached_property + def sibling_rating(self) -> "Rating | None": + from .rating import Rating + + return Rating.objects.filter(owner=self.owner, item=self.item).first() + @cached_property def mark(self) -> "Mark": from .mark import Mark @@ -405,6 +472,7 @@ class ShelfMember(ListMember): def link_post_id(self, post_id: int): if self.local: self.ensure_log_entry().link_post_id(post_id) + print(self.ensure_log_entry(), post_id) return super().link_post_id(post_id) @@ -461,6 +529,11 @@ class ShelfLogEntry(models.Model): def link_post_id(self, post_id: int): ShelfLogEntryPost.objects.get_or_create(log_entry=self, post_id=post_id) + def all_post_ids(self): + return ShelfLogEntryPost.objects.filter(log_entry=self).values_list( + "post_id", flat=True + ) + class ShelfLogEntryPost(models.Model): log_entry = models.ForeignKey(ShelfLogEntry, on_delete=models.CASCADE) diff --git a/journal/templates/mark.html b/journal/templates/mark.html index c1c6962c..e081fbf8 100644 --- a/journal/templates/mark.html +++ b/journal/templates/mark.html @@ -109,7 +109,7 @@
- {% if request.user.mastodon %} + {% if request.user.mastodon or request.user.threads or request.user.bluesky %}
+ {% if request.user.mastodon %} +
+ {% csrf_token %} + +
+ {% endif %}
@@ -120,59 +150,22 @@ {% endif %} + value="{% if request.user.threads %} {% trans 'Link with a different threads.net account' %} {% else %} {% trans "Link with a threads.net account" %} {% endif %} " /> + {% if request.user.threads %} +
+ {% csrf_token %} + +
+ {% endif %}
{% endif %} -
-
- {% trans "Display name, avatar and other information" %} -
- {% include "_field.html" with field=profile_form.name %} - {% include "_field.html" with field=profile_form.summary %} - {% include "_field.html" with field=profile_form.icon %} - {% include "_field.html" with field=profile_form.discoverable %} - {% include "_field.html" with field=profile_form.manually_approves_followers %} - {% csrf_token %} - -
-
-
-
-
- {% trans 'Users you are following' %} - {% include 'users/relationship_list.html' with id="follow" list=request.user.identity.following_identities.all %} -
-
-
-
- {% trans 'Users who follow you' %} - {% include 'users/relationship_list.html' with id="follower" list=request.user.identity.follower_identities.all %} -
-
-
-
- {% trans 'Users who request to follow you' %} - {% include 'users/relationship_list.html' with id="follow_request" list=request.user.identity.requested_follower_identities.all %} -
-
-
-
- {% trans 'Users you are muting' %} - {% include 'users/relationship_list.html' with id="mute" list=request.user.identity.muting_identities.all %} -
-
-
-
- {% trans 'Users you are blocking' %} - {% include 'users/relationship_list.html' with id="block" list=request.user.identity.blocking_identities.all %} -
-
{% trans 'Sync and import social account' %} @@ -219,6 +212,36 @@
+
+
+ {% trans 'Users you are following' %} + {% include 'users/relationship_list.html' with id="follow" list=request.user.identity.following_identities.all %} +
+
+
+
+ {% trans 'Users who follow you' %} + {% include 'users/relationship_list.html' with id="follower" list=request.user.identity.follower_identities.all %} +
+
+
+
+ {% trans 'Users who request to follow you' %} + {% include 'users/relationship_list.html' with id="follow_request" list=request.user.identity.requested_follower_identities.all %} +
+
+
+
+ {% trans 'Users you are muting' %} + {% include 'users/relationship_list.html' with id="mute" list=request.user.identity.muting_identities.all %} +
+
+
+
+ {% trans 'Users you are blocking' %} + {% include 'users/relationship_list.html' with id="block" list=request.user.identity.blocking_identities.all %} +
+
{% if allow_any_site %}
diff --git a/users/templates/users/register.html b/users/templates/users/register.html index c9211942..8e73eab8 100644 --- a/users/templates/users/register.html +++ b/users/templates/users/register.html @@ -19,7 +19,7 @@ {% if form %}
- {{ error }} + {{ error|default:"" }}