From af3142ac3adb0d1f31d160edcb6d076b293020b1 Mon Sep 17 00:00:00 2001 From: Michael Manfre Date: Thu, 15 Dec 2022 02:50:54 -0500 Subject: Basic Emoji suppport (#157) --- activities/models/__init__.py | 1 + activities/models/emoji.py | 261 ++++++++++++++++++++++++++++++++++++++++++ activities/models/fan_out.py | 16 ++- activities/models/post.py | 42 ++++--- 4 files changed, 301 insertions(+), 19 deletions(-) create mode 100644 activities/models/emoji.py (limited to 'activities/models') diff --git a/activities/models/__init__.py b/activities/models/__init__.py index aa34c0f..15a08a2 100644 --- a/activities/models/__init__.py +++ b/activities/models/__init__.py @@ -1,3 +1,4 @@ +from .emoji import Emoji, EmojiStates # noqa from .fan_out import FanOut, FanOutStates # noqa from .hashtag import Hashtag, HashtagStates # noqa from .post import Post, PostStates # noqa diff --git a/activities/models/emoji.py b/activities/models/emoji.py new file mode 100644 index 0000000..00f6e67 --- /dev/null +++ b/activities/models/emoji.py @@ -0,0 +1,261 @@ +import re +from functools import partial +from typing import ClassVar, cast + +import urlman +from asgiref.sync import sync_to_async +from django.conf import settings +from django.core.exceptions import ValidationError +from django.db import models +from django.utils.safestring import mark_safe + +from core.files import get_remote_file +from core.html import strip_html +from core.models import Config +from core.uploads import upload_emoji_namer +from core.uris import AutoAbsoluteUrl, RelativeAbsoluteUrl, StaticAbsoluteUrl +from stator.models import State, StateField, StateGraph, StatorModel +from users.models import Domain + + +class EmojiStates(StateGraph): + outdated = State(try_interval=300, force_initial=True) + updated = State() + + outdated.transitions_to(updated) + + @classmethod + async def handle_outdated(cls, instance: "Emoji"): + """ + Fetches remote emoji and uploads to file for local caching + """ + if instance.remote_url and not instance.file: + file, mimetype = await get_remote_file( + instance.remote_url, + timeout=settings.SETUP.REMOTE_TIMEOUT, + max_size=settings.SETUP.EMOJI_MAX_IMAGE_FILESIZE_KB * 1024, + ) + if file: + instance.file = file + instance.mimetype = mimetype + await sync_to_async(instance.save)() + + return cls.updated + + +class EmojiQuerySet(models.QuerySet): + def usable(self, domain: Domain | None = None): + public_q = models.Q(public=True) + if Config.system.emoji_unreviewed_are_public: + public_q |= models.Q(public__isnull=True) + + qs = self.filter(public_q) + if domain: + if domain.local: + qs = qs.filter(local=True) + else: + qs = qs.filter(domain=domain) + return qs + + +class EmojiManager(models.Manager): + def get_queryset(self): + return EmojiQuerySet(self.model, using=self._db) + + def usable(self, domain: Domain | None = None): + return self.get_queryset().usable(domain) + + +class Emoji(StatorModel): + + # Normalized Emoji without the ':' + shortcode = models.SlugField(max_length=100, db_index=True) + + domain = models.ForeignKey( + "users.Domain", null=True, blank=True, on_delete=models.CASCADE + ) + local = models.BooleanField(default=True) + + # Should this be shown in the public UI? + public = models.BooleanField(null=True) + + object_uri = models.CharField(max_length=500, blank=True, null=True, unique=True) + + mimetype = models.CharField(max_length=200) + + # Files may not be populated if it's remote and not cached on our side yet + file = models.ImageField( + upload_to=partial(upload_emoji_namer, "emoji"), + null=True, + blank=True, + ) + + # A link to the custom emoji + remote_url = models.CharField(max_length=500, blank=True, null=True) + + # Used for sorting custom emoji in the picker + category = models.CharField(max_length=100, blank=True, null=True) + + # State of this Emoji + state = StateField(EmojiStates) + + created = models.DateTimeField(auto_now_add=True) + updated = models.DateTimeField(auto_now=True) + + objects = EmojiManager() + + # Cache of the local emojis {shortcode: Emoji} + locals: ClassVar["dict[str, Emoji]"] + + class Meta: + unique_together = ("domain", "shortcode") + + class urls(urlman.Urls): + root = "/admin/emoji/" + create = "{root}/create/" + edit = "{root}{self.Emoji}/" + delete = "{edit}delete/" + + emoji_regex = re.compile(r"\B:([a-zA-Z0-9(_)-]+):\B") + + def clean(self): + super().clean() + if self.local ^ (self.domain is None): + raise ValidationError("Must be local or have a domain") + + def __str__(self): + return f"{self.id}-{self.shortcode}" + + @classmethod + def load_locals(cls) -> dict[str, "Emoji"]: + return {x.shortcode: x for x in Emoji.objects.usable().filter(local=True)} + + @property + def fullcode(self): + return f":{self.shortcode}:" + + @property + def is_usable(self) -> bool: + """ + Return True if this Emoji is usable. + """ + return self.public or ( + self.public is None and Config.system.emoji_unreviewed_are_public + ) + + def full_url(self) -> RelativeAbsoluteUrl: + if self.is_usable: + if self.file: + return AutoAbsoluteUrl(self.file.url) + elif self.remote_url: + return AutoAbsoluteUrl(f"/proxy/emoji/{self.pk}/") + return StaticAbsoluteUrl("img/blank-emoji-128.png") + + def as_html(self): + if self.is_usable: + return mark_safe( + f'Emoji {self.shortcode}' + ) + return self.fullcode + + @classmethod + def imageify_emojis( + cls, + content: str, + *, + emojis: list["Emoji"] | EmojiQuerySet | None = None, + include_local: bool = True, + ): + """ + Find :emoji: in content and convert to . If include_local is True, + the local emoji will be used as a fallback for any shortcodes not defined + by emojis. + """ + emoji_set = ( + cast(list[Emoji], list(cls.locals.values())) if include_local else [] + ) + + if emojis: + if isinstance(emojis, (EmojiQuerySet, list)): + emoji_set.extend(list(emojis)) + else: + raise TypeError("Unsupported type for emojis") + + possible_matches = { + emoji.shortcode: emoji.as_html() for emoji in emoji_set if emoji.is_usable + } + + def replacer(match): + fullcode = match.group(1).lower() + if fullcode in possible_matches: + return possible_matches[fullcode] + return match.group() + + return mark_safe(Emoji.emoji_regex.sub(replacer, content)) + + @classmethod + def emojis_from_content(cls, content: str, domain: Domain) -> list[str]: + """ + Return a parsed and sanitized of emoji found in content without + the surrounding ':'. + """ + emoji_hits = cls.emoji_regex.findall(strip_html(content)) + emojis = sorted({emoji.lower() for emoji in emoji_hits}) + return list( + cls.objects.filter(local=domain is None) + .usable(domain) + .filter(shortcode__in=emojis) + ) + + def to_ap_tag(self): + """ + Return this Emoji as an ActivityPub Tag + http://joinmastodon.org/ns#Emoji + """ + return { + "id": self.object_uri, + "type": "Emoji", + "name": self.shortcode, + "icon": { + "type": "Image", + "mediaType": self.mimetype, + "url": self.full_url().absolute, + }, + } + + @classmethod + def by_ap_tag(cls, domain: Domain, data: dict, create: bool = False): + """ """ + try: + return cls.objects.get(object_uri=data["id"]) + except cls.DoesNotExist: + if not create: + raise KeyError(f"No emoji with ID {data['id']}", data) + + # create + shortcode = data["name"].lower().strip(":") + icon = data["icon"] + category = (icon.get("category") or "")[:100] + emoji = cls.objects.create( + shortcode=shortcode, + domain=None if domain.local else domain, + local=domain.local, + object_uri=data["id"], + mimetype=icon["mediaType"], + category=category, + remote_url=icon["url"], + ) + return emoji + + ### Mastodon API ### + + def to_mastodon_json(self): + url = self.full_url().absolute + data = { + "shortcode": self.shortcode, + "url": url, + "static_url": self.remote_url or url, + "visible_in_picker": self.public, + "category": self.category or "", + } + return data diff --git a/activities/models/fan_out.py b/activities/models/fan_out.py index 8f4f342..fd52acd 100644 --- a/activities/models/fan_out.py +++ b/activities/models/fan_out.py @@ -184,8 +184,14 @@ class FanOut(StatorModel): """ Returns a version of the object with all relations pre-loaded """ - return await FanOut.objects.select_related( - "identity", - "subject_post", - "subject_post_interaction", - ).aget(pk=self.pk) + return ( + await FanOut.objects.select_related( + "identity", + "subject_post", + "subject_post_interaction", + ) + .prefetch_related( + "subject_post__emojis", + ) + .aget(pk=self.pk) + ) diff --git a/activities/models/post.py b/activities/models/post.py index 8e355bf..2b0a7c2 100644 --- a/activities/models/post.py +++ b/activities/models/post.py @@ -12,8 +12,10 @@ from django.template.defaultfilters import linebreaks_filter from django.utils import timezone from django.utils.safestring import mark_safe +from activities.models.emoji import Emoji from activities.models.fan_out import FanOut from activities.models.hashtag import Hashtag +from activities.templatetags.emoji_tags import imageify_emojis from core.html import sanitize_post, strip_html from core.ld import canonicalise, format_ld_date, get_list, parse_ld_date from stator.models import State, StateField, StateGraph, StatorModel @@ -218,6 +220,12 @@ class Post(StatorModel): # Hashtags in the post hashtags = models.JSONField(blank=True, null=True) + emojis = models.ManyToManyField( + "activities.Emoji", + related_name="posts_using_emoji", + blank=True, + ) + # When the post was originally created (as opposed to when we received it) published = models.DateTimeField(default=timezone.now) @@ -328,8 +336,11 @@ class Post(StatorModel): """ Returns the content formatted for local display """ - return Hashtag.linkify_hashtags( - self.linkify_mentions(sanitize_post(self.content), local=True) + return imageify_emojis( + Hashtag.linkify_hashtags( + self.linkify_mentions(sanitize_post(self.content), local=True) + ), + self.author.domain, ) def safe_content_remote(self): @@ -379,6 +390,8 @@ class Post(StatorModel): visibility = reply_to.Visibilities.local_only # Find hashtags in this post hashtags = Hashtag.hashtags_from_content(content) or None + # Find emoji in this post + emojis = Emoji.emojis_from_content(content, author.domain) # Strip all HTML and apply linebreaks filter content = linebreaks_filter(strip_html(content)) # Make the Post object @@ -395,6 +408,7 @@ class Post(StatorModel): post.object_uri = post.urls.object_uri post.url = post.absolute_object_uri() post.mentions.set(mentions) + post.emojis.set(emojis) if attachments: post.attachments.set(attachments) post.save() @@ -416,6 +430,7 @@ class Post(StatorModel): self.edited = timezone.now() self.hashtags = Hashtag.hashtags_from_content(content) or None self.mentions.set(self.mentions_from_content(content, self.author)) + self.emojis.set(Emoji.emojis_from_content(content, self.author.domain)) self.attachments.set(attachments or []) self.save() @@ -520,14 +535,11 @@ class Post(StatorModel): value["updated"] = format_ld_date(self.edited) # Mentions for mention in self.mentions.all(): - value["tag"].append( - { - "href": mention.actor_uri, - "name": "@" + mention.handle, - "type": "Mention", - } - ) + value["tag"].append(mention.to_ap_tag()) value["cc"].append(mention.actor_uri) + # Emoji + for emoji in self.emojis.all(): + value["tag"].append(emoji.to_ap_tag()) # Attachments for attachment in self.attachments.all(): value["attachment"].append(attachment.to_ap()) @@ -616,7 +628,9 @@ class Post(StatorModel): # Do we have one with the right ID? created = False try: - post = cls.objects.get(object_uri=data["id"]) + post = cls.objects.select_related("author__domain").get( + object_uri=data["id"] + ) except cls.DoesNotExist: if create: # Resolve the author @@ -645,10 +659,10 @@ class Post(StatorModel): mention_identity = Identity.by_actor_uri(tag["href"], create=True) post.mentions.add(mention_identity) elif tag["type"].lower() == "as:hashtag": - post.hashtags.append(tag["name"].lstrip("#")) + post.hashtags.append(tag["name"].lower().lstrip("#")) elif tag["type"].lower() == "http://joinmastodon.org/ns#emoji": - # TODO: Handle incoming emoji - pass + emoji = Emoji.by_ap_tag(post.author.domain, tag, create=True) + post.emojis.add(emoji) else: raise ValueError(f"Unknown tag type {tag['type']}") # Visibility and to @@ -818,7 +832,7 @@ class Post(StatorModel): if self.hashtags else [] ), - "emojis": [], + "emojis": [emoji.to_mastodon_json() for emoji in self.emojis.usable()], "reblogs_count": self.interactions.filter(type="boost").count(), "favourites_count": self.interactions.filter(type="like").count(), "replies_count": 0, -- cgit v1.2.3