post to threads

This commit is contained in:
Your Name 2024-07-03 16:42:20 -04:00 committed by Henri Dickson
parent 2bd3aaa78d
commit 43e9db72b7
28 changed files with 1424 additions and 1169 deletions

View file

@ -227,6 +227,7 @@ ENABLE_LOCAL_ONLY = env("NEODB_ENABLE_LOCAL_ONLY")
# Timeout of requests to Mastodon, in seconds # Timeout of requests to Mastodon, in seconds
MASTODON_TIMEOUT = env("NEODB_LOGIN_MASTODON_TIMEOUT", default=5) # type: ignore 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 TAKAHE_REMOTE_TIMEOUT = MASTODON_TIMEOUT
NEODB_USER_AGENT = f"NeoDB/{NEODB_VERSION} (+{SITE_INFO.get('site_url', 'undefined')})" NEODB_USER_AGENT = f"NeoDB/{NEODB_VERSION} (+{SITE_INFO.get('site_url', 'undefined')})"

View file

@ -122,7 +122,12 @@
{% if messages %} {% if messages %}
<ul class="messages" style="text-align:center"> <ul class="messages" style="text-align:center">
{% for message in messages %} {% for message in messages %}
<li {% if message.tags %}class="{{ message.tags }}"{% endif %}>{{ message }}</li> <li {% if message.tags %}class="{{ message.tags }}"{% endif %}>
{% if message.meta.url %}
[<a href="{{ message.meta.url }}">{% trans "Open" %}</a>]
{% endif %}
{{ message }}
</li>
{% endfor %} {% endfor %}
</ul> </ul>
{% endif %} {% endif %}

View file

@ -123,7 +123,7 @@ class Comment(Content):
ShelfManager.get_action_template(ShelfType.PROGRESS, self.item.category) 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) spoiler_text, txt = render_spoiler_text(self.text, self.item)
content = ( content = (
self.get_crosspost_template().format(item=self.item.display_title) self.get_crosspost_template().format(item=self.item.display_title)

View file

@ -5,8 +5,11 @@ from datetime import datetime
from functools import cached_property from functools import cached_property
from typing import TYPE_CHECKING, Any, Self from typing import TYPE_CHECKING, Any, Self
import django_rq
# from deepmerge import always_merger # from deepmerge import always_merger
from django.conf import settings from django.conf import settings
from django.core.exceptions import PermissionDenied
from django.core.signing import b62_decode, b62_encode from django.core.signing import b62_decode, b62_encode
from django.db import models from django.db import models
from django.db.models import CharField, Q 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 django.utils.translation import gettext_lazy as _
from loguru import logger from loguru import logger
from polymorphic.models import PolymorphicModel from polymorphic.models import PolymorphicModel
from user_messages import api as messages
from catalog.common.models import Item, ItemCategory from catalog.common.models import Item, ItemCategory
from catalog.models import item_categories, item_content_types from catalog.models import item_categories, item_content_types
from takahe.utils import Takahe from takahe.utils import Takahe
from users.middlewares import activate_language_for_user
from users.models import APIdentity, User from users.models import APIdentity, User
from .mixins import UserOwnedObjectMixin from .mixins import UserOwnedObjectMixin
@ -90,36 +95,10 @@ def q_item_in_category(item_category: ItemCategory):
return Q(item__polymorphic_ctype__in=contenttype_ids) 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): class Piece(PolymorphicModel, UserOwnedObjectMixin):
if TYPE_CHECKING: if TYPE_CHECKING:
likes: models.QuerySet["Like"] likes: models.QuerySet["Like"]
metadata: models.JSONField[Any, Any]
url_path = "p" # subclass must specify this url_path = "p" # subclass must specify this
uid = models.UUIDField(default=uuid.uuid4, editable=False, db_index=True) uid = models.UUIDField(default=uuid.uuid4, editable=False, db_index=True)
local = models.BooleanField(default=True) local = models.BooleanField(default=True)
@ -131,33 +110,16 @@ class Piece(PolymorphicModel, UserOwnedObjectMixin):
def classname(self) -> str: def classname(self) -> str:
return self.__class__.__name__.lower() 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): def delete(self, *args, **kwargs):
if self.local: if self.local:
Takahe.delete_posts(self.all_post_ids) Takahe.delete_posts(self.all_post_ids)
toot_url = self.get_mastodon_crosspost_url() toot_id = (
if toot_url and self.owner.user.mastodon: (self.metadata or {}).get("mastodon_id")
self.owner.user.mastodon.delete_later(toot_url) 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) return super().delete(*args, **kwargs)
@property @property
@ -265,7 +227,7 @@ class Piece(PolymorphicModel, UserOwnedObjectMixin):
return {} return {}
@abstractmethod @abstractmethod
def to_mastodon_params(self) -> dict[str, Any]: def to_crosspost_params(self) -> dict[str, Any]:
return {} return {}
@classmethod @classmethod
@ -314,74 +276,120 @@ class Piece(PolymorphicModel, UserOwnedObjectMixin):
# subclass may have to add additional code to update type_data in local post # subclass may have to add additional code to update type_data in local post
return p return p
def sync_to_mastodon(self, delete_existing=False): def get_crosspost_params(self):
user = self.owner.user d = {
if not user.mastodon: "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 return
if user.preference.mastodon_repost_mode == 1: try:
if delete_existing: r = threads.post(**params)
self.delete_mastodon_repost() except Exception:
return self.crosspost_to_mastodon() 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: elif self.latest_post:
if user.mastodon: mastodon.boost(self.latest_post.url)
user.mastodon.boost_later(self.latest_post.url)
else: else:
logger.warning("No post found for piece") logger.warning("No post found for piece")
def delete_mastodon_repost(self): def crosspost_to_mastodon(self, params):
toot_url = self.get_mastodon_crosspost_url() mastodon = self.owner.user.mastodon
if toot_url: if not mastodon:
self.set_mastodon_crosspost_url(None) return False
if self.owner.user.mastodon: r = mastodon.post(**params)
self.owner.user.mastodon.delete_later(toot_url) 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): def get_ap_data(self):
user = self.owner.user return {
if not user or not user.mastodon: "object": {
return False, -1 "tag": (
d = { [self.item.ap_object_ref] # type:ignore
"visibility": self.visibility, if hasattr(self, "item")
"update_toot_url": self.get_mastodon_crosspost_url(), 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 user = self.owner.user
v = Takahe.visibility_n2t(self.visibility, user.preference.post_public_mode) v = Takahe.visibility_n2t(self.visibility, user.preference.post_public_mode)
existing_post = self.latest_post existing_post = self.latest_post
if existing_post and existing_post.state in ["deleted", "deleted_fanned_out"]: if existing_post:
existing_post = None if (
elif existing_post and delete_existing: existing_post.state in ["deleted", "deleted_fanned_out"]
Takahe.delete_posts([existing_post.pk]) or update_mode == 2
existing_post = None ):
existing_post = None
elif update_mode == 1:
Takahe.delete_posts([existing_post.pk])
existing_post = None
params = { params = {
"author_pk": self.owner.pk, "author_pk": self.owner.pk,
"visibility": v, "visibility": v,
"post_pk": existing_post.pk if existing_post else None, "post_pk": existing_post.pk if existing_post else None,
"post_time": self.created_time, # type:ignore subclass must have this "post_time": self.created_time, # type:ignore subclass must have this
"edit_time": self.edited_time, # type:ignore subclass must have this "edit_time": self.edited_time, # type:ignore subclass must have this
"data": { "data": self.get_ap_data(),
"object": {
"tag": (
[self.item.ap_object_ref] # type:ignore
if hasattr(self, "item")
else []
),
"relatedWith": [self.ap_object],
}
},
} }
params.update(self.to_post_params()) params.update(self.to_post_params())
post = Takahe.post(**params) post = Takahe.post(**params)

View file

@ -18,33 +18,6 @@ from .review import Review
from .shelf import Shelf, ShelfLogEntry, ShelfManager, ShelfMember, ShelfType 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: class Mark:
""" """
Holding Mark for an item on an shelf, Holding Mark for an item on an shelf,
@ -241,6 +214,7 @@ class Mark:
visibility = self.visibility visibility = self.visibility
last_shelf_type = self.shelf_type last_shelf_type = self.shelf_type
last_visibility = self.visibility if last_shelf_type else None last_visibility = self.visibility if last_shelf_type else None
update_mode = 0
if tags is not None: if tags is not None:
self.owner.tag_manager.tag_item(self.item, tags, visibility) self.owner.tag_manager.tag_item(self.item, tags, visibility)
if shelf_type is None: if shelf_type is None:
@ -264,8 +238,7 @@ class Mark:
self.shelfmember.visibility = visibility self.shelfmember.visibility = visibility
shelfmember_changed = True shelfmember_changed = True
# retract most recent post about this status when visibility changed # retract most recent post about this status when visibility changed
if self.shelfmember.latest_post: update_mode = 1
Takahe.delete_posts([self.shelfmember.latest_post.pk])
if created_time and created_time != self.shelfmember.created_time: if created_time and created_time != self.shelfmember.created_time:
self.shelfmember.created_time = created_time self.shelfmember.created_time = created_time
log_entry.timestamp = created_time log_entry.timestamp = created_time
@ -277,6 +250,8 @@ class Mark:
if shelfmember_changed: if shelfmember_changed:
self.shelfmember.save() self.shelfmember.save()
else: 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) shelf = Shelf.objects.get(owner=self.owner, shelf_type=shelf_type)
d = {"parent": shelf, "visibility": visibility, "position": 0} d = {"parent": shelf, "visibility": visibility, "position": 0}
if metadata: if metadata:
@ -305,28 +280,17 @@ class Mark:
) )
self.rating_grade = rating_grade self.rating_grade = rating_grade
# publish a new or updated ActivityPub post # publish a new or updated ActivityPub post
user: User = self.owner.user post = self.shelfmember.sync_to_timeline(update_mode)
post_as_new = shelf_type != last_shelf_type or visibility != last_visibility if share_to_mastodon:
classic_crosspost = user.preference.mastodon_repost_mode == 1 self.shelfmember.sync_to_social_accounts(update_mode)
append = ( # auto add bookmark
f"@{user.mastodon.handle}\n" if (
if visibility > 0 post
and share_to_mastodon and shelf_type == ShelfType.PROGRESS
and not classic_crosspost and self.item.category
and user.mastodon in (self.owner.user.preference.auto_bookmark_cats or [])
else "" ):
) Takahe.bookmark(post.pk, self.owner.pk)
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
def delete(self, keep_tags=False): def delete(self, keep_tags=False):
self.update(None, tags=None if keep_tags else []) self.update(None, tags=None if keep_tags else [])

View file

@ -160,21 +160,21 @@ class Note(Content):
# if local piece is created from a post, update post type_data and fanout # if local piece is created from a post, update post type_data and fanout
p.sync_to_timeline() p.sync_to_timeline()
if owner.user.preference.mastodon_default_repost and owner.user.mastodon: if owner.user.preference.mastodon_default_repost and owner.user.mastodon:
p.sync_to_mastodon() p.sync_to_social_accounts()
return p return p
@cached_property @cached_property
def shelfmember(self) -> ShelfMember | None: def shelfmember(self) -> ShelfMember | None:
return ShelfMember.objects.filter(item=self.item, owner=self.owner).first() 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}" footer = f"\n\n{self.item.display_title}{self.progress_display}\n{self.item.absolute_url}"
params = { params = {
"spoiler_text": self.title, "spoiler_text": self.title,
"content": self.content + footer, "content": self.content + footer,
"sensitive": self.sensitive, "sensitive": self.sensitive,
"reply_to_toot_url": ( "reply_to_id": (
self.shelfmember.get_mastodon_crosspost_url() (self.shelfmember.metadata or {}).get("mastodon_id")
if self.shelfmember if self.shelfmember
else None else None
), ),

View file

@ -86,7 +86,7 @@ class Review(Content):
def get_crosspost_template(self): def get_crosspost_template(self):
return _(ShelfManager.get_action_template("reviewed", self.item.category)) return _(ShelfManager.get_action_template("reviewed", self.item.category))
def to_mastodon_params(self): def to_crosspost_params(self):
content = ( content = (
self.get_crosspost_template().format(item=self.item.display_title) self.get_crosspost_template().format(item=self.item.display_title)
+ f"\n{self.title}\n{self.absolute_url} " + f"\n{self.title}\n{self.absolute_url} "
@ -153,7 +153,8 @@ class Review(Content):
review, created = cls.objects.update_or_create( review, created = cls.objects.update_or_create(
item=item, owner=owner, defaults=defaults 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: if share_to_mastodon:
review.sync_to_mastodon(delete_existing=delete_existing_post) review.sync_to_social_accounts(update_mode)
return review return review

View file

@ -2,11 +2,10 @@ from datetime import datetime
from functools import cached_property from functools import cached_property
from typing import TYPE_CHECKING from typing import TYPE_CHECKING
from django.conf import settings
from django.db import connection, models from django.db import connection, models
from django.utils import timezone from django.utils import timezone
from django.utils.translation import gettext_lazy as _ 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 loguru import logger
from catalog.models import Item, ItemCategory from catalog.models import Item, ItemCategory
@ -15,9 +14,12 @@ from users.models import APIdentity
from .common import q_item_in_category from .common import q_item_in_category
from .itemlist import List, ListMember from .itemlist import List, ListMember
from .renderers import render_post_with_macro, render_rating
if TYPE_CHECKING: if TYPE_CHECKING:
from .comment import Comment
from .mark import Mark from .mark import Mark
from .rating import Rating
class ShelfType(models.TextChoices): class ShelfType(models.TextChoices):
@ -356,6 +358,71 @@ class ShelfMember(ListMember):
p.link_post_id(post.id) p.link_post_id(post.id)
return p 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'<a href="{item_link}">{self.item.display_title}</a>'
)
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 @cached_property
def mark(self) -> "Mark": def mark(self) -> "Mark":
from .mark import Mark from .mark import Mark
@ -405,6 +472,7 @@ class ShelfMember(ListMember):
def link_post_id(self, post_id: int): def link_post_id(self, post_id: int):
if self.local: if self.local:
self.ensure_log_entry().link_post_id(post_id) self.ensure_log_entry().link_post_id(post_id)
print(self.ensure_log_entry(), post_id)
return super().link_post_id(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): def link_post_id(self, post_id: int):
ShelfLogEntryPost.objects.get_or_create(log_entry=self, post_id=post_id) 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): class ShelfLogEntryPost(models.Model):
log_entry = models.ForeignKey(ShelfLogEntry, on_delete=models.CASCADE) log_entry = models.ForeignKey(ShelfLogEntry, on_delete=models.CASCADE)

View file

@ -109,7 +109,7 @@
</div> </div>
<div> <div>
<fieldset> <fieldset>
{% if request.user.mastodon %} {% if request.user.mastodon or request.user.threads or request.user.bluesky %}
<label for="id_share_to_mastodon"> <label for="id_share_to_mastodon">
<input role="switch" <input role="switch"
type="checkbox" type="checkbox"

View file

@ -180,10 +180,10 @@ def share_collection(
) )
) )
content = f"{user_str}:{collection.title}\n{link}\n{comment}{tags}" content = f"{user_str}:{collection.title}\n{link}\n{comment}{tags}"
response = user.mastodon.post(content, visibility) try:
if response is not None and response.status_code in [200, 201]: user.mastodon.post(content, visibility)
return True return True
else: except Exception:
return False return False

View file

@ -183,9 +183,10 @@ def comment(request: AuthedHttpRequest, item_uuid):
comment = Comment.objects.update_or_create( comment = Comment.objects.update_or_create(
owner=request.user.identity, item=item, defaults=d owner=request.user.identity, item=item, defaults=d
)[0] )[0]
comment.sync_to_timeline(delete_existing=delete_existing_post) update_mode = 1 if delete_existing_post else 0
comment.sync_to_timeline(update_mode)
if share_to_mastodon: if share_to_mastodon:
comment.sync_to_mastodon(delete_existing=delete_existing_post) comment.sync_to_social_accounts(update_mode)
return HttpResponseRedirect(request.META.get("HTTP_REFERER", "/")) return HttpResponseRedirect(request.META.get("HTTP_REFERER", "/"))

View file

@ -108,7 +108,8 @@ def note_edit(request: AuthedHttpRequest, item_uuid: str, note_uuid: str = ""):
delete_existing_post = ( delete_existing_post = (
orig_visibility is not None and orig_visibility != note.visibility orig_visibility is not None and orig_visibility != note.visibility
) )
note.sync_to_timeline(delete_existing=delete_existing_post) update_mode = 1 if delete_existing_post else 0
note.sync_to_timeline(update_mode)
if form.cleaned_data["share_to_mastodon"]: if form.cleaned_data["share_to_mastodon"]:
note.sync_to_mastodon(delete_existing=delete_existing_post) note.sync_to_social_accounts(update_mode)
return HttpResponseRedirect(request.META.get("HTTP_REFERER", "/")) return HttpResponseRedirect(request.META.get("HTTP_REFERER", "/"))

View file

@ -129,9 +129,12 @@ class WrappedShareView(LoginRequiredMixin, TemplateView):
) )
classic_crosspost = user.preference.mastodon_repost_mode == 1 classic_crosspost = user.preference.mastodon_repost_mode == 1
if classic_crosspost and user.mastodon: if classic_crosspost and user.mastodon:
user.mastodon.post( try:
comment, visibility, attachments=[("year.png", img, "image/png")] user.mastodon.post(
) comment, visibility, attachments=[("year.png", img, "image/png")]
)
except Exception:
pass
elif post and user.mastodon: elif post and user.mastodon:
user.mastodon.boost_later(post.url) user.mastodon.boost_later(post.url)
messages.add_message(request, messages.INFO, _("Summary posted to timeline.")) messages.add_message(request, messages.INFO, _("Summary posted to timeline."))

File diff suppressed because it is too large Load diff

File diff suppressed because it is too large Load diff

View file

@ -10,6 +10,7 @@ import django_rq
import requests import requests
from django.conf import settings from django.conf import settings
from django.core.cache import cache from django.core.cache import cache
from django.core.exceptions import PermissionDenied, RequestAborted
from django.db import models from django.db import models
from django.db.models import Count from django.db.models import Count
from django.http import HttpRequest from django.http import HttpRequest
@ -139,12 +140,11 @@ def boost_toot(domain, token, toot_url):
return None return None
def delete_toot(api_domain, access_token, toot_url): def delete_toot(api_domain, access_token, toot_id):
headers = { headers = {
"User-Agent": USER_AGENT, "User-Agent": USER_AGENT,
"Authorization": f"Bearer {access_token}", "Authorization": f"Bearer {access_token}",
} }
toot_id = get_status_id_by_url(toot_url)
url = "https://" + api_domain + API_PUBLISH_TOOT + "/" + toot_id url = "https://" + api_domain + API_PUBLISH_TOOT + "/" + toot_id
try: try:
response = delete(url, headers=headers) response = delete(url, headers=headers)
@ -159,8 +159,8 @@ def post_toot2(
access_token: str, access_token: str,
content: str, content: str,
visibility: TootVisibilityEnum, visibility: TootVisibilityEnum,
update_toot_url: str | None = None, update_id: str | None = None,
reply_to_toot_url: str | None = None, reply_to_id: str | None = None,
sensitive: bool = False, sensitive: bool = False,
spoiler_text: str | None = None, spoiler_text: str | None = None,
attachments: list = [], attachments: list = [],
@ -177,8 +177,8 @@ def post_toot2(
"status": content, "status": content,
"visibility": visibility, "visibility": visibility,
} }
update_id = get_status_id_by_url(update_toot_url) # update_id = get_status_id_by_url(update_toot_url)
reply_to_id = get_status_id_by_url(reply_to_toot_url) # reply_to_id = get_status_id_by_url(reply_to_toot_url)
if reply_to_id: if reply_to_id:
payload["in_reply_to_id"] = reply_to_id payload["in_reply_to_id"] = reply_to_id
if spoiler_text: if spoiler_text:
@ -415,7 +415,9 @@ def obtain_token(site, code, request):
try: try:
response = post(url, data=payload, headers=headers, auth=auth) response = post(url, data=payload, headers=headers, auth=auth)
if response.status_code != 200: if response.status_code != 200:
logger.warning(f"Error {url} {response.status_code}") logger.warning(
f"Error {url} {payload} {response.status_code} {response.content}"
)
return None, None return None, None
except Exception as e: except Exception as e:
logger.warning(f"Error {url} {e}") logger.warning(f"Error {url} {e}")
@ -467,6 +469,13 @@ def get_or_create_fediverse_application(login_domain):
if not app: if not app:
app = MastodonApplication.objects.filter(api_domain__iexact=domain).first() app = MastodonApplication.objects.filter(api_domain__iexact=domain).first()
if app: if app:
if " Firefish " in app.server_version:
data = create_app(app.api_domain, True).json()
app.app_id = data["id"]
app.client_id = data["client_id"]
app.client_secret = data["client_secret"]
app.vapid_key = data.get("vapid_key", "")
app.save()
return app return app
if not settings.MASTODON_ALLOW_ANY_SITE: if not settings.MASTODON_ALLOW_ANY_SITE:
logger.warning(f"Disallowed to create app for {domain}") logger.warning(f"Disallowed to create app for {domain}")
@ -804,38 +813,55 @@ class MastodonAccount(SocialAccount):
] ]
) )
def boost(self, post_url: str):
boost_toot(self.api_domain, self.access_token, post_url)
def boost_later(self, post_url: str): def boost_later(self, post_url: str):
django_rq.get_queue("fetch").enqueue( django_rq.get_queue("fetch").enqueue(
boost_toot, self.api_domain, self.access_token, post_url boost_toot, self.api_domain, self.access_token, post_url
) )
def delete_later(self, post_url: str): def delete_post(self, post_id: str):
delete_toot(self.api_domain, self.access_token, post_id)
def delete_post_later(self, post_id: str):
django_rq.get_queue("fetch").enqueue( django_rq.get_queue("fetch").enqueue(
delete_toot, self.api_domain, self.access_token, post_url delete_toot, self.api_domain, self.access_token, post_id
) )
def post( def post(
self, self,
content: str, content: str,
visibility: "VisibilityType", visibility: "VisibilityType",
update_toot_url: str | None = None, update_id: str | None = None,
reply_to_toot_url: str | None = None, reply_to_id: str | None = None,
sensitive: bool = False, sensitive: bool = False,
spoiler_text: str | None = None, spoiler_text: str | None = None,
attachments: list = [], attachments: list = [],
) -> requests.Response | None: ) -> dict:
v = get_toot_visibility(visibility, self.user) v = get_toot_visibility(visibility, self.user)
return post_toot2( response = post_toot2(
self.api_domain, self.api_domain,
self.access_token, self.access_token,
content, content,
v, v,
update_toot_url, update_id,
reply_to_toot_url, reply_to_id,
sensitive, sensitive,
spoiler_text, spoiler_text,
attachments, attachments,
) )
if response:
if response.status_code in [200, 201]:
j = response.json()
return {"id": j["id"], "url": j["url"]}
elif response.status_code == 401:
raise PermissionDenied()
raise RequestAborted()
def sync_later(self): def sync_later(self):
Takahe.fetch_remote_identity(self.handle) Takahe.fetch_remote_identity(self.handle)
# TODO
def get_reauthorize_url(self):
return reverse("mastodon:connect") + "?domain=" + self.domain

View file

@ -1,8 +1,10 @@
import functools import functools
from datetime import timedelta from datetime import timedelta
from urllib.parse import quote
import requests import requests
from django.conf import settings from django.conf import settings
from django.core.exceptions import RequestAborted
from django.http import HttpRequest from django.http import HttpRequest
from django.urls import reverse from django.urls import reverse
from django.utils import timezone from django.utils import timezone
@ -14,22 +16,22 @@ from .common import SocialAccount
get = functools.partial( get = functools.partial(
requests.get, requests.get,
timeout=settings.MASTODON_TIMEOUT, timeout=settings.THREADS_TIMEOUT,
headers={"User-Agent": settings.NEODB_USER_AGENT}, headers={"User-Agent": settings.NEODB_USER_AGENT},
) )
put = functools.partial( put = functools.partial(
requests.put, requests.put,
timeout=settings.MASTODON_TIMEOUT, timeout=settings.THREADS_TIMEOUT,
headers={"User-Agent": settings.NEODB_USER_AGENT}, headers={"User-Agent": settings.NEODB_USER_AGENT},
) )
post = functools.partial( post = functools.partial(
requests.post, requests.post,
timeout=settings.MASTODON_TIMEOUT, timeout=settings.THREADS_TIMEOUT,
headers={"User-Agent": settings.NEODB_USER_AGENT}, headers={"User-Agent": settings.NEODB_USER_AGENT},
) )
delete = functools.partial( delete = functools.partial(
requests.post, requests.post,
timeout=settings.MASTODON_TIMEOUT, timeout=settings.THREADS_TIMEOUT,
headers={"User-Agent": settings.NEODB_USER_AGENT}, headers={"User-Agent": settings.NEODB_USER_AGENT},
) )
@ -109,7 +111,7 @@ class Threads:
def get_profile( def get_profile(
token: str, user_id: str | None = None token: str, user_id: str | None = None
) -> dict[str, str | int] | None: ) -> dict[str, str | int] | None:
url = f'https://graph.threads.net/v1.0/{user_id or "me"}?fields=id,username,name,threads_profile_picture_url,threads_biography&access_token={token}' url = f'https://graph.threads.net/v1.0/{user_id or "me"}?fields=id,username,threads_profile_picture_url,threads_biography&access_token={token}'
try: try:
response = get(url) response = get(url)
except Exception as e: except Exception as e:
@ -124,6 +126,30 @@ class Threads:
return None return None
return data return data
@staticmethod
def post_single(token: str, user_id: str, text: str):
url = f"https://graph.threads.net/v1.0/{user_id}/threads?media_type=TEXT&access_token={token}&text={quote(text)}"
response = post(url)
if response.status_code != 200:
return None
media_container_id = (response.json() or {}).get("id")
if not media_container_id:
return None
url = f"https://graph.threads.net/v1.0/{user_id}/threads_publish?creation_id={media_container_id}&access_token={token}"
response = post(url)
if response.status_code != 200:
return None
return (response.json() or {}).get("id")
@staticmethod
def get_single(token: str, media_id: str) -> dict | None:
# url = f"https://graph.threads.net/v1.0/{media_id}?fields=id,media_product_type,media_type,media_url,permalink,owner,username,text,timestamp,shortcode,thumbnail_url,children,is_quote_post&access_token={token}"
url = f"https://graph.threads.net/v1.0/{media_id}?fields=id,permalink,is_quote_post&access_token={token}"
response = post(url)
if response.status_code != 200:
return None
return response.json()
@staticmethod @staticmethod
def authenticate(request: HttpRequest, code: str) -> "ThreadsAccount | None": def authenticate(request: HttpRequest, code: str) -> "ThreadsAccount | None":
token, expire, uid = Threads.obtain_token(request, code) token, expire, uid = Threads.obtain_token(request, code)
@ -195,7 +221,7 @@ class ThreadsAccount(SocialAccount):
return False return False
data = Threads.get_profile(self.access_token) data = Threads.get_profile(self.access_token)
if not data: if not data:
logger.warning("{self} unable to get profile") logger.warning(f"{self} unable to get profile")
return False return False
if self.handle != data["username"]: if self.handle != data["username"]:
if self.handle: if self.handle:
@ -206,3 +232,16 @@ class ThreadsAccount(SocialAccount):
if save: if save:
self.save(update_fields=["account_data", "handle", "last_refresh"]) self.save(update_fields=["account_data", "handle", "last_refresh"])
return True return True
def post(self, content: str, **kwargs):
text = (
content.replace(settings.STAR_SOLID + " ", "🌕")
.replace(settings.STAR_HALF + " ", "🌗")
.replace(settings.STAR_EMPTY + " ", "🌑")
)
media_id = Threads.post_single(self.access_token, self.uid, text)
if media_id:
d = Threads.get_single(self.access_token, media_id)
if d:
return {"id": media_id, "url": d["permalink"]}
raise RequestAborted()

View file

@ -8,7 +8,7 @@ urlpatterns = [
path("login/oauth", mastodon_oauth, name="oauth"), path("login/oauth", mastodon_oauth, name="oauth"),
path("mastodon/login", mastodon_login, name="login"), path("mastodon/login", mastodon_login, name="login"),
path("mastodon/reconnect", mastodon_reconnect, name="reconnect"), path("mastodon/reconnect", mastodon_reconnect, name="reconnect"),
path("mastodon/disconnect", mastodon_disconnect, name="mastodon_disconnect"), path("mastodon/disconnect", mastodon_disconnect, name="disconnect"),
# Email # Email
path("email/login", email_login, name="email_login"), path("email/login", email_login, name="email_login"),
path("email/verify", email_verify, name="email_verify"), path("email/verify", email_verify, name="email_verify"),

View file

@ -80,7 +80,8 @@ def disconnect_identity(request, account):
with transaction.atomic(): with transaction.atomic():
if request.user.social_accounts.all().count() <= 1: if request.user.social_accounts.all().count() <= 1:
return render_error( return render_error(
_("Unlink identity failed"), _("Unable to unlink last login identity.") _("Disconnect identity failed"),
_("You cannot disconnect last login identity."),
) )
account.delete() account.delete()
return redirect(reverse("users:info")) return redirect(reverse("users:info"))

View file

@ -49,9 +49,13 @@ def threads_oauth(request: HttpRequest):
@require_http_methods(["GET"]) @require_http_methods(["GET"])
def threads_uninstall(request: HttpRequest): def threads_uninstall(request: HttpRequest):
print(request.GET)
print(request.POST)
return redirect(reverse("users:data")) return redirect(reverse("users:data"))
@require_http_methods(["GET"]) @require_http_methods(["GET"])
def threads_delete(request: HttpRequest): def threads_delete(request: HttpRequest):
print(request.GET)
print(request.POST)
return redirect(reverse("users:data")) return redirect(reverse("users:data"))

View file

@ -57,6 +57,7 @@ dependencies = [
"deepmerge>=1.1.1", "deepmerge>=1.1.1",
"django-typed-models @ git+https://github.com/alphatownsman/django-typed-models.git", "django-typed-models @ git+https://github.com/alphatownsman/django-typed-models.git",
"atproto>=0.0.48", "atproto>=0.0.48",
"pyright>=1.1.370",
] ]
[tool.rye] [tool.rye]
@ -94,7 +95,7 @@ django_settings_module = "boofilsic.settings"
[tool.ruff] [tool.ruff]
exclude = ["neodb-takahe/*", "media", ".venv", ".git", "playground", "**/tests.py", "neodb", "**/migrations", "**/commands", "**/importers", "**/sites", "legacy" ] exclude = ["neodb-takahe/*", "media", ".venv", ".git", "playground", "**/tests.py", "neodb", "**/migrations", "**/commands", "**/importers", "**/sites", "legacy" ]
lint.ignore = ["F403", "F405"] lint.ignore = ["F401", "F403", "F405"]
[tool.setuptools] [tool.setuptools]
py-modules = [] py-modules = []

View file

@ -229,7 +229,7 @@ pygments==2.18.0
# via mkdocs-material # via mkdocs-material
pymdown-extensions==10.8.1 pymdown-extensions==10.8.1
# via mkdocs-material # via mkdocs-material
pyright==1.1.369 pyright==1.1.370
python-dateutil==2.9.0.post0 python-dateutil==2.9.0.post0
# via dateparser # via dateparser
# via django-auditlog # via django-auditlog

View file

@ -126,6 +126,8 @@ mistune==3.0.2
multidict==6.0.5 multidict==6.0.5
# via aiohttp # via aiohttp
# via yarl # via yarl
nodeenv==1.9.1
# via pyright
oauthlib==3.2.2 oauthlib==3.2.2
# via django-oauth-toolkit # via django-oauth-toolkit
openpyxl==3.1.3 openpyxl==3.1.3
@ -146,6 +148,7 @@ pydantic==2.7.3
# via django-ninja # via django-ninja
pydantic-core==2.18.4 pydantic-core==2.18.4
# via pydantic # via pydantic
pyright==1.1.370
python-dateutil==2.9.0.post0 python-dateutil==2.9.0.post0
# via dateparser # via dateparser
# via django-auditlog # via django-auditlog

View file

@ -8,7 +8,7 @@
{% load user_actions %} {% load user_actions %}
{% load duration %} {% load duration %}
{% for event in events %} {% for event in events %}
{% with post=event.subject_post piece=event.subject_post.piece item=event.subject_post.piece.item %} {% with post=event.subject_post piece=event.subject_post.piece item=event.subject_post.item %}
{% if not post %} {% if not post %}
<!-- invalid data {{ event.pk }} --> <!-- invalid data {{ event.pk }} -->
{% else %} {% else %}
@ -70,7 +70,7 @@
{% trans "wrote a note" %} {% trans "wrote a note" %}
{% endif %} {% endif %}
</span> </span>
{% if piece and item and piece.classname != 'note' %} {% if item and piece.classname != 'note' %}
<article>{% include "_item_card.html" with item=item allow_embed=1 %}</article> <article>{% include "_item_card.html" with item=item allow_embed=1 %}</article>
{% endif %} {% endif %}
<div>{{ post.summary|default:'' }}</div> <div>{{ post.summary|default:'' }}</div>

View file

@ -1092,6 +1092,16 @@ class Post(models.Model):
return pcs[0] return pcs[0]
return next((p for p in pcs if p.__class__ == ShelfMember), None) return next((p for p in pcs if p.__class__ == ShelfMember), None)
@cached_property
def item(self):
from journal.models import ShelfLogEntry
p = self.piece
if p:
return p.item if hasattr(p, "item") else None # type:ignore
log = ShelfLogEntry.objects.filter(shelflogentrypost__post_id=self.pk).first()
return log.item if log else None
@classmethod @classmethod
def create_local( def create_local(
cls, cls,

View file

@ -626,51 +626,6 @@ class Takahe:
collection.link_post_id(post.pk) collection.link_post_id(post.pk)
return post return post
@staticmethod
def post_mark(mark, share_as_new_post: bool, append_content="") -> Post | None:
user = mark.owner.user
stars = _rating_to_emoji(mark.rating_grade, 1)
item_link = f"{settings.SITE_INFO['site_url']}/~neodb~{mark.item.url}"
prepend_content = mark.get_action_for_feed(item_link=item_link)
spoiler, txt = Takahe.get_spoiler_text(mark.comment_text, mark.item)
content = f"{stars} \n{txt}\n{mark.tag_text}"
data = {
"object": {
"tag": [mark.item.ap_object_ref],
"relatedWith": [mark.shelfmember.ap_object],
}
}
if mark.comment:
data["object"]["relatedWith"].append(mark.comment.ap_object)
if mark.rating:
data["object"]["relatedWith"].append(mark.rating.ap_object)
v = Takahe.visibility_n2t(mark.visibility, user.preference.post_public_mode)
existing_post = (
None
if share_as_new_post
or mark.shelfmember.latest_post is None
or mark.shelfmember.latest_post.state in ["deleted", "deleted_fanned_out"]
else mark.shelfmember.latest_post
)
post = Takahe.post(
mark.owner.pk,
content + append_content,
v,
prepend_content,
"",
spoiler,
spoiler is not None,
data,
existing_post.pk if existing_post else None,
mark.shelfmember.created_time,
)
if not post:
return
for piece in [mark.shelfmember, mark.comment, mark.rating]:
if piece:
piece.link_post_id(post.pk)
return post
@staticmethod @staticmethod
def interact_post(post_pk: int, identity_pk: int, type: str): def interact_post(post_pk: int, identity_pk: int, type: str):
post = Post.objects.filter(pk=post_pk).first() post = Post.objects.filter(pk=post_pk).first()

View file

@ -14,23 +14,43 @@
{% include "_header.html" %} {% include "_header.html" %}
<main> <main>
<div class="grid__main"> <div class="grid__main">
<article>
<details>
<summary>{% trans "Display name, avatar and other information" %}</summary>
<form action="{% url 'users:profile' %}?next={{ request.path }}"
method="post"
{% if request.user.mastodon and not request.user.preference.mastodon_skip_userinfo %}onsubmit="return confirm('{% trans "Updating profile information here will turn off automatic sync of display name, bio and avatar from your Mastodon instance. Sure to continue?" %}')"{% endif %}
enctype="multipart/form-data">
<label>
{% trans "Username" %}
<input value="{{ request.user.username }}"
aria-invalid="false"
readonly
disabled />
</label>
{% 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 %}
<input type="submit" value="{% trans 'Save' %}" id="save">
</form>
</details>
</article>
{% if allow_any_site %} {% if allow_any_site %}
<article> <article>
<details> <details>
<summary>{% trans 'Username and Email' %}</summary> <summary>{% trans 'Email' %}</summary>
<form action="{% url 'users:register' %}?next={{ request.path }}" <form action="{% url 'users:register' %}?next={{ request.path }}"
method="post"> method="post">
<small>{{ error }}</small> <input value="{{ request.user.username }}" type="hidden" name="username" />
<fieldset> <fieldset>
<label>
{% trans "Username" %}
<input name="username" _="on input remove [@disabled] from #save end" placeholder="{% trans "2-30 alphabets, numbers or underscore, can't be changed once saved" %}" required {% if request.user.username %}value="{{ request.user.username }}" aria-invalid="false" readonly{% endif %} pattern="^[a-zA-Z0-9_]{2,30}$" />
</label>
<label> <label>
{% trans "Email address" %} {% trans "Email address" %}
<input type="email" <input type="email"
name="email" name="email"
_="on input remove [@disabled] from #save then remove [@aria-invalid] end" _="on input remove [@disabled] from #save_email then remove [@aria-invalid] end"
{% if request.user.email_account %}value="{{ request.user.email_account.handle }}" aria-invalid="false"{% endif %} {% if request.user.email_account %}value="{{ request.user.email_account.handle }}" aria-invalid="false"{% endif %}
placeholder="email" placeholder="email"
autocomplete="email" /> autocomplete="email" />
@ -44,7 +64,7 @@
</label> </label>
</fieldset> </fieldset>
{% csrf_token %} {% csrf_token %}
<input type="submit" value="{% trans 'Save' %}" disabled id="save"> <input type="submit" value="{% trans 'Save' %}" disabled id="save_email">
</form> </form>
</details> </details>
</article> </article>
@ -97,6 +117,16 @@
</small> </small>
</fieldset> </fieldset>
</form> </form>
{% if request.user.mastodon %}
<form action="{% url 'mastodon:disconnect' %}"
method="post"
onsubmit="return confirm('{% trans "Once disconnected, you will no longer be able login with this identity. Are you sure to continue?" %}')">
{% csrf_token %}
<input type="submit"
value="{% trans 'Disconnect with this identity' %}"
class="secondary" />
</form>
{% endif %}
</details> </details>
</article> </article>
<article> <article>
@ -120,59 +150,22 @@
</label> </label>
{% endif %} {% endif %}
<input type="submit" <input type="submit"
{% if request.user.threads %} value="{% trans 'Link with a different threads.net account' %}" {% else %} {% trans "Link with a threads.net account" %} {% endif %} /> value="{% if request.user.threads %} {% trans 'Link with a different threads.net account' %} {% else %} {% trans "Link with a threads.net account" %} {% endif %} " />
</fieldset> </fieldset>
</form> </form>
{% if request.user.threads %}
<form action="{% url 'mastodon:threads_login' %}"
method="post"
onsubmit="return confirm('{% trans "Once disconnected, you will no longer be able login with this identity. Are you sure to continue?" %}')">
{% csrf_token %}
<input type="submit"
value="{% trans 'Disconnect with Threads' %}"
class="secondary" />
</form>
{% endif %}
</details> </details>
</article> </article>
{% endif %} {% endif %}
<article>
<details>
<summary>{% trans "Display name, avatar and other information" %}</summary>
<form action="{% url 'users:profile' %}?next={{ request.path }}"
method="post"
{% if request.user.mastodon and not request.user.preference.mastodon_skip_userinfo %}onsubmit="return confirm('{% trans "Updating profile information here will turn off automatic sync of display name, bio and avatar from your Mastodon instance. Sure to continue?" %}')"{% endif %}
enctype="multipart/form-data">
{% 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 %}
<input type="submit" value="{% trans 'Save' %}" id="save">
</form>
</details>
</article>
<article>
<details>
<summary>{% trans 'Users you are following' %}</summary>
{% include 'users/relationship_list.html' with id="follow" list=request.user.identity.following_identities.all %}
</details>
</article>
<article>
<details>
<summary>{% trans 'Users who follow you' %}</summary>
{% include 'users/relationship_list.html' with id="follower" list=request.user.identity.follower_identities.all %}
</details>
</article>
<article>
<details>
<summary>{% trans 'Users who request to follow you' %}</summary>
{% include 'users/relationship_list.html' with id="follow_request" list=request.user.identity.requested_follower_identities.all %}
</details>
</article>
<article>
<details>
<summary>{% trans 'Users you are muting' %}</summary>
{% include 'users/relationship_list.html' with id="mute" list=request.user.identity.muting_identities.all %}
</details>
</article>
<article>
<details>
<summary>{% trans 'Users you are blocking' %}</summary>
{% include 'users/relationship_list.html' with id="block" list=request.user.identity.blocking_identities.all %}
</details>
</article>
<article> <article>
<details> <details>
<summary>{% trans 'Sync and import social account' %}</summary> <summary>{% trans 'Sync and import social account' %}</summary>
@ -219,6 +212,36 @@
</form> </form>
</details> </details>
</article> </article>
<article>
<details>
<summary>{% trans 'Users you are following' %}</summary>
{% include 'users/relationship_list.html' with id="follow" list=request.user.identity.following_identities.all %}
</details>
</article>
<article>
<details>
<summary>{% trans 'Users who follow you' %}</summary>
{% include 'users/relationship_list.html' with id="follower" list=request.user.identity.follower_identities.all %}
</details>
</article>
<article>
<details>
<summary>{% trans 'Users who request to follow you' %}</summary>
{% include 'users/relationship_list.html' with id="follow_request" list=request.user.identity.requested_follower_identities.all %}
</details>
</article>
<article>
<details>
<summary>{% trans 'Users you are muting' %}</summary>
{% include 'users/relationship_list.html' with id="mute" list=request.user.identity.muting_identities.all %}
</details>
</article>
<article>
<details>
<summary>{% trans 'Users you are blocking' %}</summary>
{% include 'users/relationship_list.html' with id="block" list=request.user.identity.blocking_identities.all %}
</details>
</article>
{% if allow_any_site %} {% if allow_any_site %}
<article> <article>
<details> <details>

View file

@ -19,7 +19,7 @@
</header> </header>
{% if form %} {% if form %}
<form action="{% url 'users:register' %}" method="post"> <form action="{% url 'users:register' %}" method="post">
<small>{{ error }}</small> <small>{{ error|default:"" }}</small>
<fieldset> <fieldset>
<label> <label>
{% blocktrans %}Your username on {{ site_name }}{% endblocktrans %} {% blocktrans %}Your username on {{ site_name }}{% endblocktrans %}