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''
+ )
+ 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