mirror of
https://github.com/jointakahe/takahe.git
synced 2024-11-28 18:21:00 +00:00
Basic Emoji suppport (#157)
This commit is contained in:
parent
69f1b3168a
commit
af3142ac3a
33 changed files with 670 additions and 42 deletions
|
@ -46,4 +46,4 @@ repos:
|
||||||
rev: v0.991
|
rev: v0.991
|
||||||
hooks:
|
hooks:
|
||||||
- id: mypy
|
- id: mypy
|
||||||
additional_dependencies: [types-pyopenssl, types-bleach, types-mock]
|
additional_dependencies: [types-pyopenssl, types-bleach, types-mock, types-cachetools]
|
||||||
|
|
|
@ -1,8 +1,10 @@
|
||||||
from asgiref.sync import async_to_sync
|
from asgiref.sync import async_to_sync
|
||||||
from django.contrib import admin
|
from django.contrib import admin
|
||||||
|
from django.utils.safestring import mark_safe
|
||||||
from django.utils.translation import gettext_lazy as _
|
from django.utils.translation import gettext_lazy as _
|
||||||
|
|
||||||
from activities.models import (
|
from activities.models import (
|
||||||
|
Emoji,
|
||||||
FanOut,
|
FanOut,
|
||||||
Hashtag,
|
Hashtag,
|
||||||
Post,
|
Post,
|
||||||
|
@ -50,6 +52,46 @@ class HashtagAdmin(admin.ModelAdmin):
|
||||||
instance.transition_perform("outdated")
|
instance.transition_perform("outdated")
|
||||||
|
|
||||||
|
|
||||||
|
@admin.register(Emoji)
|
||||||
|
class EmojiAdmin(admin.ModelAdmin):
|
||||||
|
list_display = (
|
||||||
|
"shortcode",
|
||||||
|
"preview",
|
||||||
|
"local",
|
||||||
|
"domain",
|
||||||
|
"public",
|
||||||
|
"state",
|
||||||
|
"created",
|
||||||
|
)
|
||||||
|
list_filter = ("local", "public", "state")
|
||||||
|
search_fields = ("shortcode",)
|
||||||
|
|
||||||
|
readonly_fields = ("preview", "created", "updated")
|
||||||
|
|
||||||
|
actions = ["force_execution", "approve_emoji", "reject_emoji"]
|
||||||
|
|
||||||
|
@admin.action(description="Force Execution")
|
||||||
|
def force_execution(self, request, queryset):
|
||||||
|
for instance in queryset:
|
||||||
|
instance.transition_perform("outdated")
|
||||||
|
|
||||||
|
@admin.action(description="Approve Emoji")
|
||||||
|
def approve_emoji(self, request, queryset):
|
||||||
|
queryset.update(public=True)
|
||||||
|
|
||||||
|
@admin.action(description="Reject Emoji")
|
||||||
|
def reject_emoji(self, request, queryset):
|
||||||
|
queryset.update(public=False)
|
||||||
|
|
||||||
|
@admin.display(description="Emoji Preview")
|
||||||
|
def preview(self, instance):
|
||||||
|
if instance.public is False:
|
||||||
|
return mark_safe(f'<a href="{instance.full_url().relative}">Preview</a>')
|
||||||
|
return mark_safe(
|
||||||
|
f'<img src="{instance.full_url().relative}" style="height: 22px">'
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
@admin.register(PostAttachment)
|
@admin.register(PostAttachment)
|
||||||
class PostAttachmentAdmin(admin.ModelAdmin):
|
class PostAttachmentAdmin(admin.ModelAdmin):
|
||||||
list_display = ["id", "post", "created"]
|
list_display = ["id", "post", "created"]
|
||||||
|
|
27
activities/middleware.py
Normal file
27
activities/middleware.py
Normal file
|
@ -0,0 +1,27 @@
|
||||||
|
from time import time
|
||||||
|
|
||||||
|
from activities.models import Emoji
|
||||||
|
|
||||||
|
|
||||||
|
class EmojiDefaultsLoadingMiddleware:
|
||||||
|
"""
|
||||||
|
Caches the default Emoji
|
||||||
|
"""
|
||||||
|
|
||||||
|
refresh_interval: float = 30.0
|
||||||
|
|
||||||
|
def __init__(self, get_response):
|
||||||
|
self.get_response = get_response
|
||||||
|
self.loaded_ts: float = 0.0
|
||||||
|
|
||||||
|
def __call__(self, request):
|
||||||
|
# Allow test fixtures to force and lock the Emojis
|
||||||
|
if not getattr(Emoji, "__forced__", False):
|
||||||
|
if (
|
||||||
|
not getattr(Emoji, "locals", None)
|
||||||
|
or (time() - self.loaded_ts) >= self.refresh_interval
|
||||||
|
):
|
||||||
|
Emoji.locals = Emoji.load_locals()
|
||||||
|
self.loaded_ts = time()
|
||||||
|
response = self.get_response(request)
|
||||||
|
return response
|
91
activities/migrations/0004_emoji_post_emojis.py
Normal file
91
activities/migrations/0004_emoji_post_emojis.py
Normal file
|
@ -0,0 +1,91 @@
|
||||||
|
# Generated by Django 4.1.4 on 2022-12-14 23:49
|
||||||
|
|
||||||
|
import functools
|
||||||
|
|
||||||
|
import django.db.models.deletion
|
||||||
|
from django.db import migrations, models
|
||||||
|
|
||||||
|
import activities.models.emoji
|
||||||
|
import core.uploads
|
||||||
|
import stator.models
|
||||||
|
|
||||||
|
|
||||||
|
class Migration(migrations.Migration):
|
||||||
|
|
||||||
|
dependencies = [
|
||||||
|
("users", "0003_identity_followers_etc"),
|
||||||
|
("activities", "0003_postattachment_null_thumb"),
|
||||||
|
]
|
||||||
|
|
||||||
|
operations = [
|
||||||
|
migrations.CreateModel(
|
||||||
|
name="Emoji",
|
||||||
|
fields=[
|
||||||
|
(
|
||||||
|
"id",
|
||||||
|
models.BigAutoField(
|
||||||
|
auto_created=True,
|
||||||
|
primary_key=True,
|
||||||
|
serialize=False,
|
||||||
|
verbose_name="ID",
|
||||||
|
),
|
||||||
|
),
|
||||||
|
("state_ready", models.BooleanField(default=True)),
|
||||||
|
("state_changed", models.DateTimeField(auto_now_add=True)),
|
||||||
|
("state_attempted", models.DateTimeField(blank=True, null=True)),
|
||||||
|
("state_locked_until", models.DateTimeField(blank=True, null=True)),
|
||||||
|
("shortcode", models.SlugField(max_length=100)),
|
||||||
|
("local", models.BooleanField(default=True)),
|
||||||
|
("public", models.BooleanField(null=True)),
|
||||||
|
(
|
||||||
|
"object_uri",
|
||||||
|
models.CharField(
|
||||||
|
blank=True, max_length=500, null=True, unique=True
|
||||||
|
),
|
||||||
|
),
|
||||||
|
("mimetype", models.CharField(max_length=200)),
|
||||||
|
(
|
||||||
|
"file",
|
||||||
|
models.ImageField(
|
||||||
|
blank=True,
|
||||||
|
null=True,
|
||||||
|
upload_to=functools.partial(
|
||||||
|
core.uploads.upload_emoji_namer, *("emoji",), **{}
|
||||||
|
),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
("remote_url", models.CharField(blank=True, max_length=500, null=True)),
|
||||||
|
("category", models.CharField(blank=True, max_length=100, null=True)),
|
||||||
|
(
|
||||||
|
"state",
|
||||||
|
stator.models.StateField(
|
||||||
|
choices=[("outdated", "outdated"), ("updated", "updated")],
|
||||||
|
default="outdated",
|
||||||
|
graph=activities.models.emoji.EmojiStates,
|
||||||
|
max_length=100,
|
||||||
|
),
|
||||||
|
),
|
||||||
|
("created", models.DateTimeField(auto_now_add=True)),
|
||||||
|
("updated", models.DateTimeField(auto_now=True)),
|
||||||
|
(
|
||||||
|
"domain",
|
||||||
|
models.ForeignKey(
|
||||||
|
blank=True,
|
||||||
|
null=True,
|
||||||
|
on_delete=django.db.models.deletion.CASCADE,
|
||||||
|
to="users.domain",
|
||||||
|
),
|
||||||
|
),
|
||||||
|
],
|
||||||
|
options={
|
||||||
|
"unique_together": {("domain", "shortcode")},
|
||||||
|
},
|
||||||
|
),
|
||||||
|
migrations.AddField(
|
||||||
|
model_name="post",
|
||||||
|
name="emojis",
|
||||||
|
field=models.ManyToManyField(
|
||||||
|
blank=True, related_name="posts_using_emoji", to="activities.emoji"
|
||||||
|
),
|
||||||
|
),
|
||||||
|
]
|
|
@ -1,3 +1,4 @@
|
||||||
|
from .emoji import Emoji, EmojiStates # noqa
|
||||||
from .fan_out import FanOut, FanOutStates # noqa
|
from .fan_out import FanOut, FanOutStates # noqa
|
||||||
from .hashtag import Hashtag, HashtagStates # noqa
|
from .hashtag import Hashtag, HashtagStates # noqa
|
||||||
from .post import Post, PostStates # noqa
|
from .post import Post, PostStates # noqa
|
||||||
|
|
261
activities/models/emoji.py
Normal file
261
activities/models/emoji.py
Normal file
|
@ -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'<img src="{self.full_url().relative}" class="emoji" alt="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 <img>. 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
|
|
@ -184,8 +184,14 @@ class FanOut(StatorModel):
|
||||||
"""
|
"""
|
||||||
Returns a version of the object with all relations pre-loaded
|
Returns a version of the object with all relations pre-loaded
|
||||||
"""
|
"""
|
||||||
return await FanOut.objects.select_related(
|
return (
|
||||||
|
await FanOut.objects.select_related(
|
||||||
"identity",
|
"identity",
|
||||||
"subject_post",
|
"subject_post",
|
||||||
"subject_post_interaction",
|
"subject_post_interaction",
|
||||||
).aget(pk=self.pk)
|
)
|
||||||
|
.prefetch_related(
|
||||||
|
"subject_post__emojis",
|
||||||
|
)
|
||||||
|
.aget(pk=self.pk)
|
||||||
|
)
|
||||||
|
|
|
@ -12,8 +12,10 @@ from django.template.defaultfilters import linebreaks_filter
|
||||||
from django.utils import timezone
|
from django.utils import timezone
|
||||||
from django.utils.safestring import mark_safe
|
from django.utils.safestring import mark_safe
|
||||||
|
|
||||||
|
from activities.models.emoji import Emoji
|
||||||
from activities.models.fan_out import FanOut
|
from activities.models.fan_out import FanOut
|
||||||
from activities.models.hashtag import Hashtag
|
from activities.models.hashtag import Hashtag
|
||||||
|
from activities.templatetags.emoji_tags import imageify_emojis
|
||||||
from core.html import sanitize_post, strip_html
|
from core.html import sanitize_post, strip_html
|
||||||
from core.ld import canonicalise, format_ld_date, get_list, parse_ld_date
|
from core.ld import canonicalise, format_ld_date, get_list, parse_ld_date
|
||||||
from stator.models import State, StateField, StateGraph, StatorModel
|
from stator.models import State, StateField, StateGraph, StatorModel
|
||||||
|
@ -218,6 +220,12 @@ class Post(StatorModel):
|
||||||
# Hashtags in the post
|
# Hashtags in the post
|
||||||
hashtags = models.JSONField(blank=True, null=True)
|
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)
|
# When the post was originally created (as opposed to when we received it)
|
||||||
published = models.DateTimeField(default=timezone.now)
|
published = models.DateTimeField(default=timezone.now)
|
||||||
|
|
||||||
|
@ -328,8 +336,11 @@ class Post(StatorModel):
|
||||||
"""
|
"""
|
||||||
Returns the content formatted for local display
|
Returns the content formatted for local display
|
||||||
"""
|
"""
|
||||||
return Hashtag.linkify_hashtags(
|
return imageify_emojis(
|
||||||
|
Hashtag.linkify_hashtags(
|
||||||
self.linkify_mentions(sanitize_post(self.content), local=True)
|
self.linkify_mentions(sanitize_post(self.content), local=True)
|
||||||
|
),
|
||||||
|
self.author.domain,
|
||||||
)
|
)
|
||||||
|
|
||||||
def safe_content_remote(self):
|
def safe_content_remote(self):
|
||||||
|
@ -379,6 +390,8 @@ class Post(StatorModel):
|
||||||
visibility = reply_to.Visibilities.local_only
|
visibility = reply_to.Visibilities.local_only
|
||||||
# Find hashtags in this post
|
# Find hashtags in this post
|
||||||
hashtags = Hashtag.hashtags_from_content(content) or None
|
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
|
# Strip all HTML and apply linebreaks filter
|
||||||
content = linebreaks_filter(strip_html(content))
|
content = linebreaks_filter(strip_html(content))
|
||||||
# Make the Post object
|
# Make the Post object
|
||||||
|
@ -395,6 +408,7 @@ class Post(StatorModel):
|
||||||
post.object_uri = post.urls.object_uri
|
post.object_uri = post.urls.object_uri
|
||||||
post.url = post.absolute_object_uri()
|
post.url = post.absolute_object_uri()
|
||||||
post.mentions.set(mentions)
|
post.mentions.set(mentions)
|
||||||
|
post.emojis.set(emojis)
|
||||||
if attachments:
|
if attachments:
|
||||||
post.attachments.set(attachments)
|
post.attachments.set(attachments)
|
||||||
post.save()
|
post.save()
|
||||||
|
@ -416,6 +430,7 @@ class Post(StatorModel):
|
||||||
self.edited = timezone.now()
|
self.edited = timezone.now()
|
||||||
self.hashtags = Hashtag.hashtags_from_content(content) or None
|
self.hashtags = Hashtag.hashtags_from_content(content) or None
|
||||||
self.mentions.set(self.mentions_from_content(content, self.author))
|
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.attachments.set(attachments or [])
|
||||||
self.save()
|
self.save()
|
||||||
|
|
||||||
|
@ -520,14 +535,11 @@ class Post(StatorModel):
|
||||||
value["updated"] = format_ld_date(self.edited)
|
value["updated"] = format_ld_date(self.edited)
|
||||||
# Mentions
|
# Mentions
|
||||||
for mention in self.mentions.all():
|
for mention in self.mentions.all():
|
||||||
value["tag"].append(
|
value["tag"].append(mention.to_ap_tag())
|
||||||
{
|
|
||||||
"href": mention.actor_uri,
|
|
||||||
"name": "@" + mention.handle,
|
|
||||||
"type": "Mention",
|
|
||||||
}
|
|
||||||
)
|
|
||||||
value["cc"].append(mention.actor_uri)
|
value["cc"].append(mention.actor_uri)
|
||||||
|
# Emoji
|
||||||
|
for emoji in self.emojis.all():
|
||||||
|
value["tag"].append(emoji.to_ap_tag())
|
||||||
# Attachments
|
# Attachments
|
||||||
for attachment in self.attachments.all():
|
for attachment in self.attachments.all():
|
||||||
value["attachment"].append(attachment.to_ap())
|
value["attachment"].append(attachment.to_ap())
|
||||||
|
@ -616,7 +628,9 @@ class Post(StatorModel):
|
||||||
# Do we have one with the right ID?
|
# Do we have one with the right ID?
|
||||||
created = False
|
created = False
|
||||||
try:
|
try:
|
||||||
post = cls.objects.get(object_uri=data["id"])
|
post = cls.objects.select_related("author__domain").get(
|
||||||
|
object_uri=data["id"]
|
||||||
|
)
|
||||||
except cls.DoesNotExist:
|
except cls.DoesNotExist:
|
||||||
if create:
|
if create:
|
||||||
# Resolve the author
|
# Resolve the author
|
||||||
|
@ -645,10 +659,10 @@ class Post(StatorModel):
|
||||||
mention_identity = Identity.by_actor_uri(tag["href"], create=True)
|
mention_identity = Identity.by_actor_uri(tag["href"], create=True)
|
||||||
post.mentions.add(mention_identity)
|
post.mentions.add(mention_identity)
|
||||||
elif tag["type"].lower() == "as:hashtag":
|
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":
|
elif tag["type"].lower() == "http://joinmastodon.org/ns#emoji":
|
||||||
# TODO: Handle incoming emoji
|
emoji = Emoji.by_ap_tag(post.author.domain, tag, create=True)
|
||||||
pass
|
post.emojis.add(emoji)
|
||||||
else:
|
else:
|
||||||
raise ValueError(f"Unknown tag type {tag['type']}")
|
raise ValueError(f"Unknown tag type {tag['type']}")
|
||||||
# Visibility and to
|
# Visibility and to
|
||||||
|
@ -818,7 +832,7 @@ class Post(StatorModel):
|
||||||
if self.hashtags
|
if self.hashtags
|
||||||
else []
|
else []
|
||||||
),
|
),
|
||||||
"emojis": [],
|
"emojis": [emoji.to_mastodon_json() for emoji in self.emojis.usable()],
|
||||||
"reblogs_count": self.interactions.filter(type="boost").count(),
|
"reblogs_count": self.interactions.filter(type="boost").count(),
|
||||||
"favourites_count": self.interactions.filter(type="like").count(),
|
"favourites_count": self.interactions.filter(type="like").count(),
|
||||||
"replies_count": 0,
|
"replies_count": 0,
|
||||||
|
|
27
activities/templatetags/emoji_tags.py
Normal file
27
activities/templatetags/emoji_tags.py
Normal file
|
@ -0,0 +1,27 @@
|
||||||
|
from cachetools import TTLCache, cached
|
||||||
|
from django import template
|
||||||
|
|
||||||
|
from activities.models import Emoji
|
||||||
|
from users.models import Domain
|
||||||
|
|
||||||
|
register = template.Library()
|
||||||
|
|
||||||
|
|
||||||
|
@cached(cache=TTLCache(maxsize=1000, ttl=60))
|
||||||
|
def emoji_from_domain(domain: Domain | None) -> list[Emoji]:
|
||||||
|
if not domain:
|
||||||
|
return list(Emoji.locals.values())
|
||||||
|
return list(Emoji.objects.usable(domain))
|
||||||
|
|
||||||
|
|
||||||
|
@register.filter
|
||||||
|
def imageify_emojis(value: str, arg: Domain | None = None):
|
||||||
|
"""
|
||||||
|
Convert hashtags in content in to /tags/<hashtag>/ links.
|
||||||
|
"""
|
||||||
|
if not value:
|
||||||
|
return ""
|
||||||
|
|
||||||
|
emojis = emoji_from_domain(arg)
|
||||||
|
|
||||||
|
return Emoji.imageify_emojis(value, emojis=emojis)
|
|
@ -67,6 +67,8 @@ class Individual(TemplateView):
|
||||||
in_reply_to=self.post_obj.object_uri,
|
in_reply_to=self.post_obj.object_uri,
|
||||||
)
|
)
|
||||||
.distinct()
|
.distinct()
|
||||||
|
.select_related("author__domain")
|
||||||
|
.prefetch_related("emojis")
|
||||||
.order_by("published", "created"),
|
.order_by("published", "created"),
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
|
@ -98,8 +98,8 @@ class Local(ListView):
|
||||||
def get_queryset(self):
|
def get_queryset(self):
|
||||||
return (
|
return (
|
||||||
Post.objects.local_public()
|
Post.objects.local_public()
|
||||||
.select_related("author")
|
.select_related("author", "author__domain")
|
||||||
.prefetch_related("attachments", "mentions")
|
.prefetch_related("attachments", "mentions", "emojis")
|
||||||
.order_by("-created")[:50]
|
.order_by("-created")[:50]
|
||||||
)
|
)
|
||||||
|
|
||||||
|
@ -126,8 +126,8 @@ class Federated(ListView):
|
||||||
Post.objects.filter(
|
Post.objects.filter(
|
||||||
visibility=Post.Visibilities.public, in_reply_to__isnull=True
|
visibility=Post.Visibilities.public, in_reply_to__isnull=True
|
||||||
)
|
)
|
||||||
.select_related("author")
|
.select_related("author", "author__domain")
|
||||||
.prefetch_related("attachments", "mentions")
|
.prefetch_related("attachments", "mentions", "emojis")
|
||||||
.order_by("-created")[:50]
|
.order_by("-created")[:50]
|
||||||
)
|
)
|
||||||
|
|
||||||
|
@ -173,7 +173,13 @@ class Notifications(ListView):
|
||||||
return (
|
return (
|
||||||
TimelineEvent.objects.filter(identity=self.request.identity, type__in=types)
|
TimelineEvent.objects.filter(identity=self.request.identity, type__in=types)
|
||||||
.order_by("-created")[:50]
|
.order_by("-created")[:50]
|
||||||
.select_related("subject_post", "subject_post__author", "subject_identity")
|
.select_related(
|
||||||
|
"subject_post",
|
||||||
|
"subject_post__author",
|
||||||
|
"subject_post__author__domain",
|
||||||
|
"subject_identity",
|
||||||
|
)
|
||||||
|
.prefetch_related("subject_post__emojis")
|
||||||
)
|
)
|
||||||
|
|
||||||
def get_context_data(self, **kwargs):
|
def get_context_data(self, **kwargs):
|
||||||
|
|
|
@ -1,7 +1,10 @@
|
||||||
import io
|
import io
|
||||||
|
|
||||||
import blurhash
|
import blurhash
|
||||||
|
import httpx
|
||||||
|
from django.conf import settings
|
||||||
from django.core.files import File
|
from django.core.files import File
|
||||||
|
from django.core.files.base import ContentFile
|
||||||
from PIL import Image, ImageOps
|
from PIL import Image, ImageOps
|
||||||
|
|
||||||
|
|
||||||
|
@ -37,3 +40,28 @@ def blurhash_image(file) -> str:
|
||||||
Returns the blurhash for an image
|
Returns the blurhash for an image
|
||||||
"""
|
"""
|
||||||
return blurhash.encode(file, 4, 4)
|
return blurhash.encode(file, 4, 4)
|
||||||
|
|
||||||
|
|
||||||
|
async def get_remote_file(
|
||||||
|
url: str,
|
||||||
|
*,
|
||||||
|
timeout: float = settings.SETUP.REMOTE_TIMEOUT,
|
||||||
|
max_size: int | None = None,
|
||||||
|
) -> tuple[File | None, str | None]:
|
||||||
|
"""
|
||||||
|
Download a URL and return the File and content-type.
|
||||||
|
"""
|
||||||
|
async with httpx.AsyncClient() as client:
|
||||||
|
async with client.stream("GET", url, timeout=timeout) as stream:
|
||||||
|
allow_download = max_size is None
|
||||||
|
if max_size:
|
||||||
|
try:
|
||||||
|
content_length = int(stream.headers["content-length"])
|
||||||
|
allow_download = content_length <= max_size
|
||||||
|
except TypeError:
|
||||||
|
pass
|
||||||
|
if allow_download:
|
||||||
|
file = ContentFile(await stream.aread(), name=url)
|
||||||
|
return file, stream.headers["content-type"]
|
||||||
|
|
||||||
|
return None, None
|
||||||
|
|
|
@ -224,6 +224,8 @@ class Config(models.Model):
|
||||||
hashtag_unreviewed_are_public: bool = True
|
hashtag_unreviewed_are_public: bool = True
|
||||||
hashtag_stats_max_age: int = 60 * 60
|
hashtag_stats_max_age: int = 60 * 60
|
||||||
|
|
||||||
|
emoji_unreviewed_are_public: bool = False
|
||||||
|
|
||||||
cache_timeout_page_default: int = 60
|
cache_timeout_page_default: int = 60
|
||||||
cache_timeout_page_timeline: int = 60 * 3
|
cache_timeout_page_timeline: int = 60 * 3
|
||||||
cache_timeout_page_post: int = 60 * 2
|
cache_timeout_page_post: int = 60 * 2
|
||||||
|
|
|
@ -1,10 +1,14 @@
|
||||||
import os
|
import os
|
||||||
import secrets
|
import secrets
|
||||||
|
from typing import TYPE_CHECKING
|
||||||
|
|
||||||
from django.utils import timezone
|
from django.utils import timezone
|
||||||
from storages.backends.gcloud import GoogleCloudStorage
|
from storages.backends.gcloud import GoogleCloudStorage
|
||||||
from storages.backends.s3boto3 import S3Boto3Storage
|
from storages.backends.s3boto3 import S3Boto3Storage
|
||||||
|
|
||||||
|
if TYPE_CHECKING:
|
||||||
|
from activities.models import Emoji
|
||||||
|
|
||||||
|
|
||||||
def upload_namer(prefix, instance, filename):
|
def upload_namer(prefix, instance, filename):
|
||||||
"""
|
"""
|
||||||
|
@ -16,6 +20,18 @@ def upload_namer(prefix, instance, filename):
|
||||||
return f"{prefix}/{now.year}/{now.month}/{now.day}/{new_filename}{old_extension}"
|
return f"{prefix}/{now.year}/{now.month}/{now.day}/{new_filename}{old_extension}"
|
||||||
|
|
||||||
|
|
||||||
|
def upload_emoji_namer(prefix, instance: "Emoji", filename):
|
||||||
|
"""
|
||||||
|
Names uploaded emoji per domain
|
||||||
|
"""
|
||||||
|
_, old_extension = os.path.splitext(filename)
|
||||||
|
if instance.domain is None:
|
||||||
|
domain = "_default"
|
||||||
|
else:
|
||||||
|
domain = instance.domain.domain
|
||||||
|
return f"{prefix}/{domain}/{instance.shortcode}{old_extension}"
|
||||||
|
|
||||||
|
|
||||||
class TakaheS3Storage(S3Boto3Storage):
|
class TakaheS3Storage(S3Boto3Storage):
|
||||||
"""
|
"""
|
||||||
Custom override backend that makes webp files store correctly
|
Custom override backend that makes webp files store correctly
|
||||||
|
|
|
@ -5,7 +5,7 @@ from django.http import Http404, HttpResponse
|
||||||
from django.shortcuts import get_object_or_404
|
from django.shortcuts import get_object_or_404
|
||||||
from django.views.generic import View
|
from django.views.generic import View
|
||||||
|
|
||||||
from activities.models import PostAttachment
|
from activities.models import Emoji, PostAttachment
|
||||||
from users.models import Identity
|
from users.models import Identity
|
||||||
|
|
||||||
|
|
||||||
|
@ -57,6 +57,21 @@ class BaseCacheView(View):
|
||||||
raise NotImplementedError()
|
raise NotImplementedError()
|
||||||
|
|
||||||
|
|
||||||
|
class EmojiCacheView(BaseCacheView):
|
||||||
|
"""
|
||||||
|
Caches Emoji
|
||||||
|
"""
|
||||||
|
|
||||||
|
item_timeout = 86400 * 7 # One week
|
||||||
|
|
||||||
|
def get_remote_url(self):
|
||||||
|
self.emoji = get_object_or_404(Emoji, pk=self.kwargs["emoji_id"])
|
||||||
|
|
||||||
|
if not self.emoji.remote_url:
|
||||||
|
raise Http404()
|
||||||
|
return self.emoji.remote_url
|
||||||
|
|
||||||
|
|
||||||
class IdentityIconCacheView(BaseCacheView):
|
class IdentityIconCacheView(BaseCacheView):
|
||||||
"""
|
"""
|
||||||
Caches identity icons (avatars)
|
Caches identity icons (avatars)
|
||||||
|
|
|
@ -1,5 +1,6 @@
|
||||||
bleach~=5.0.1
|
bleach~=5.0.1
|
||||||
blurhash-python~=1.1.3
|
blurhash-python~=1.1.3
|
||||||
|
cachetools~=5.2.0
|
||||||
cryptography~=38.0
|
cryptography~=38.0
|
||||||
dj_database_url~=1.0.0
|
dj_database_url~=1.0.0
|
||||||
django-cache-url~=3.4.2
|
django-cache-url~=3.4.2
|
||||||
|
|
|
@ -358,6 +358,10 @@ nav a i {
|
||||||
width: auto;
|
width: auto;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.icon-menu .option img.emoji {
|
||||||
|
height: 22px;
|
||||||
|
}
|
||||||
|
|
||||||
.icon-menu .option i {
|
.icon-menu .option i {
|
||||||
display: inline-block;
|
display: inline-block;
|
||||||
text-align: center;
|
text-align: center;
|
||||||
|
@ -740,6 +744,10 @@ h1.identity .icon {
|
||||||
margin: 0 20px 0 0;
|
margin: 0 20px 0 0;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
h1.identity .emoji {
|
||||||
|
height: 22px;
|
||||||
|
}
|
||||||
|
|
||||||
h1.identity small {
|
h1.identity small {
|
||||||
display: block;
|
display: block;
|
||||||
font-size: 60%;
|
font-size: 60%;
|
||||||
|
@ -752,6 +760,10 @@ h1.identity small {
|
||||||
margin: 0 0 20px 0;
|
margin: 0 0 20px 0;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.bio .emoji {
|
||||||
|
height: 22px;
|
||||||
|
}
|
||||||
|
|
||||||
.system-note {
|
.system-note {
|
||||||
background: var(--color-bg-menu);
|
background: var(--color-bg-menu);
|
||||||
color: var(--color-text-dull);
|
color: var(--color-text-dull);
|
||||||
|
@ -789,6 +801,10 @@ table.metadata td.name {
|
||||||
gap: 1em;
|
gap: 1em;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
table.metadata td .emoji {
|
||||||
|
height: 22px;
|
||||||
|
}
|
||||||
|
|
||||||
/* Timelines */
|
/* Timelines */
|
||||||
|
|
||||||
.left-column .timeline-name {
|
.left-column .timeline-name {
|
||||||
|
@ -857,6 +873,10 @@ table.metadata td.name {
|
||||||
float: left;
|
float: left;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.post .emoji {
|
||||||
|
height: 22px;
|
||||||
|
}
|
||||||
|
|
||||||
.post .handle {
|
.post .handle {
|
||||||
display: block;
|
display: block;
|
||||||
padding: 7px 0 0 64px;
|
padding: 7px 0 0 64px;
|
||||||
|
@ -1014,6 +1034,13 @@ table.metadata td.name {
|
||||||
padding: 0 0 3px 5px;
|
padding: 0 0 3px 5px;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.boost-banner .emoji,
|
||||||
|
.mention-banner .emoji,
|
||||||
|
.follow-banner .emoji,
|
||||||
|
.like-banner .emoji {
|
||||||
|
height: 22px;
|
||||||
|
}
|
||||||
|
|
||||||
.boost-banner a,
|
.boost-banner a,
|
||||||
.mention-banner a,
|
.mention-banner a,
|
||||||
.follow-banner a,
|
.follow-banner a,
|
||||||
|
|
BIN
static/img/blank-emoji-128.png
Normal file
BIN
static/img/blank-emoji-128.png
Normal file
Binary file not shown.
After Width: | Height: | Size: 194 B |
|
@ -107,6 +107,11 @@ class Settings(BaseSettings):
|
||||||
#: is necessary for compatibility with Mastodon’s image proxy.
|
#: is necessary for compatibility with Mastodon’s image proxy.
|
||||||
MEDIA_MAX_IMAGE_FILESIZE_MB: int = 10
|
MEDIA_MAX_IMAGE_FILESIZE_MB: int = 10
|
||||||
|
|
||||||
|
#: Maximum filesize for Emoji. Attempting to upload Local Emoji larger than this size will be
|
||||||
|
#: blocked. Remote Emoji larger than this size will not be fetched and served from media, but
|
||||||
|
#: served through the image proxy.
|
||||||
|
EMOJI_MAX_IMAGE_FILESIZE_KB: int = 200
|
||||||
|
|
||||||
#: Request timeouts to use when talking to other servers Either
|
#: Request timeouts to use when talking to other servers Either
|
||||||
#: float or tuple of floats for (connect, read, write, pool)
|
#: float or tuple of floats for (connect, read, write, pool)
|
||||||
REMOTE_TIMEOUT: float | tuple[float, float, float, float] = 5.0
|
REMOTE_TIMEOUT: float | tuple[float, float, float, float] = 5.0
|
||||||
|
@ -194,6 +199,7 @@ MIDDLEWARE = [
|
||||||
"core.middleware.ConfigLoadingMiddleware",
|
"core.middleware.ConfigLoadingMiddleware",
|
||||||
"api.middleware.ApiTokenMiddleware",
|
"api.middleware.ApiTokenMiddleware",
|
||||||
"users.middleware.IdentityMiddleware",
|
"users.middleware.IdentityMiddleware",
|
||||||
|
"activities.middleware.EmojiDefaultsLoadingMiddleware",
|
||||||
]
|
]
|
||||||
|
|
||||||
ROOT_URLCONF = "takahe.urls"
|
ROOT_URLCONF = "takahe.urls"
|
||||||
|
|
|
@ -194,6 +194,11 @@ urlpatterns = [
|
||||||
mediaproxy.PostAttachmentCacheView.as_view(),
|
mediaproxy.PostAttachmentCacheView.as_view(),
|
||||||
name="proxy_post_attachment",
|
name="proxy_post_attachment",
|
||||||
),
|
),
|
||||||
|
path(
|
||||||
|
"proxy/emoji/<emoji_id>/",
|
||||||
|
mediaproxy.EmojiCacheView.as_view(),
|
||||||
|
name="proxy_emoji",
|
||||||
|
),
|
||||||
# Well-known endpoints and system actor
|
# Well-known endpoints and system actor
|
||||||
path(".well-known/webfinger", activitypub.Webfinger.as_view()),
|
path(".well-known/webfinger", activitypub.Webfinger.as_view()),
|
||||||
path(".well-known/host-meta", activitypub.HostMeta.as_view()),
|
path(".well-known/host-meta", activitypub.HostMeta.as_view()),
|
||||||
|
|
|
@ -3,14 +3,14 @@
|
||||||
{% if event.type == "followed" %}
|
{% if event.type == "followed" %}
|
||||||
<div class="follow-banner">
|
<div class="follow-banner">
|
||||||
<a href="{{ event.subject_identity.urls.view }}">
|
<a href="{{ event.subject_identity.urls.view }}">
|
||||||
{{ event.subject_identity.name_or_handle }}
|
{{ event.subject_identity.html_name_or_handle }}
|
||||||
</a> followed you
|
</a> followed you
|
||||||
</div>
|
</div>
|
||||||
{% include "activities/_identity.html" with identity=event.subject_identity created=event.created %}
|
{% include "activities/_identity.html" with identity=event.subject_identity created=event.created %}
|
||||||
{% elif event.type == "liked" %}
|
{% elif event.type == "liked" %}
|
||||||
<div class="like-banner">
|
<div class="like-banner">
|
||||||
<a href="{{ event.subject_identity.urls.view }}">
|
<a href="{{ event.subject_identity.urls.view }}">
|
||||||
{{ event.subject_identity.name_or_handle }}
|
{{ event.subject_identity.html_name_or_handle }}
|
||||||
</a> liked your post
|
</a> liked your post
|
||||||
</div>
|
</div>
|
||||||
{% if not event.collapsed %}
|
{% if not event.collapsed %}
|
||||||
|
@ -19,7 +19,7 @@
|
||||||
{% elif event.type == "mentioned" %}
|
{% elif event.type == "mentioned" %}
|
||||||
<div class="mention-banner">
|
<div class="mention-banner">
|
||||||
<a href="{{ event.subject_identity.urls.view }}">
|
<a href="{{ event.subject_identity.urls.view }}">
|
||||||
{{ event.subject_identity.name_or_handle }}
|
{{ event.subject_identity.html_name_or_handle }}
|
||||||
</a> mentioned you
|
</a> mentioned you
|
||||||
</div>
|
</div>
|
||||||
{% if not event.collapsed %}
|
{% if not event.collapsed %}
|
||||||
|
@ -28,7 +28,7 @@
|
||||||
{% elif event.type == "boosted" %}
|
{% elif event.type == "boosted" %}
|
||||||
<div class="boost-banner">
|
<div class="boost-banner">
|
||||||
<a href="{{ event.subject_identity.urls.view }}">
|
<a href="{{ event.subject_identity.urls.view }}">
|
||||||
{{ event.subject_identity.name_or_handle }}
|
{{ event.subject_identity.html_name_or_handle }}
|
||||||
</a> boosted your post
|
</a> boosted your post
|
||||||
</div>
|
</div>
|
||||||
{% if not event.collapsed %}
|
{% if not event.collapsed %}
|
||||||
|
|
|
@ -12,6 +12,6 @@
|
||||||
{% endif %}
|
{% endif %}
|
||||||
|
|
||||||
<a href="{{ identity.urls.view }}" class="handle">
|
<a href="{{ identity.urls.view }}" class="handle">
|
||||||
{{ identity.name_or_handle }} <small>@{{ identity.handle }}</small>
|
{{ identity.html_name_or_handle }} <small>@{{ identity.handle }}</small>
|
||||||
</a>
|
</a>
|
||||||
</div>
|
</div>
|
||||||
|
|
|
@ -7,7 +7,7 @@
|
||||||
</a>
|
</a>
|
||||||
|
|
||||||
<a href="{{ post.author.urls.view }}" class="handle">
|
<a href="{{ post.author.urls.view }}" class="handle">
|
||||||
{{ post.author.name_or_handle }}
|
{{ post.author.html_name_or_handle }}
|
||||||
</a>
|
</a>
|
||||||
|
|
||||||
<div class="content">
|
<div class="content">
|
||||||
|
|
|
@ -59,7 +59,7 @@
|
||||||
{% endif %}
|
{% endif %}
|
||||||
|
|
||||||
<a href="{{ post.author.urls.view }}" class="handle">
|
<a href="{{ post.author.urls.view }}" class="handle">
|
||||||
{{ post.author.name_or_handle }} <small>@{{ post.author.handle }}</small>
|
{{ post.author.html_name_or_handle }} <small>@{{ post.author.handle }}</small>
|
||||||
</a>
|
</a>
|
||||||
|
|
||||||
{% if post.summary %}
|
{% if post.summary %}
|
||||||
|
|
|
@ -8,7 +8,7 @@
|
||||||
<a class="option" href="{{ identity.urls.view }}">
|
<a class="option" href="{{ identity.urls.view }}">
|
||||||
<img src="{{ identity.local_icon_url.relative }}">
|
<img src="{{ identity.local_icon_url.relative }}">
|
||||||
<span class="handle">
|
<span class="handle">
|
||||||
{{ identity.name_or_handle }}
|
{{ identity.html_name_or_handle }}
|
||||||
<small>@{{ identity.handle }}</small>
|
<small>@{{ identity.handle }}</small>
|
||||||
</span>
|
</span>
|
||||||
{% if details.outbound %}
|
{% if details.outbound %}
|
||||||
|
|
|
@ -10,7 +10,7 @@
|
||||||
{% elif event.type == "boost" %}
|
{% elif event.type == "boost" %}
|
||||||
<div class="boost-banner">
|
<div class="boost-banner">
|
||||||
<a href="{{ event.subject_identity.urls.view }}">
|
<a href="{{ event.subject_identity.urls.view }}">
|
||||||
{{ event.subject_identity.name_or_handle }}
|
{{ event.subject_identity.html_name_or_handle }}
|
||||||
</a> boosted
|
</a> boosted
|
||||||
<time>
|
<time>
|
||||||
{{ event.subject_post_interaction.published | timedeltashort }} ago
|
{{ event.subject_post_interaction.published | timedeltashort }} ago
|
||||||
|
|
|
@ -1,6 +1,6 @@
|
||||||
{% extends "base.html" %}
|
{% extends "base.html" %}
|
||||||
|
|
||||||
{% block title %}Post by {{ post.author.name_or_handle }}{% endblock %}
|
{% block title %}Post by {{ post.author.html_name_or_handle }}{% endblock %}
|
||||||
|
|
||||||
{% block content %}
|
{% block content %}
|
||||||
{% if parent %}
|
{% if parent %}
|
||||||
|
|
|
@ -8,7 +8,7 @@
|
||||||
<a class="option" href="{{ identity.urls.activate }}">
|
<a class="option" href="{{ identity.urls.activate }}">
|
||||||
<img src="{{ identity.local_icon_url.relative }}">
|
<img src="{{ identity.local_icon_url.relative }}">
|
||||||
<span class="handle">
|
<span class="handle">
|
||||||
{{ identity.name_or_handle }}
|
{{ identity.html_name_or_handle }}
|
||||||
<small>@{{ identity.handle }}</small>
|
<small>@{{ identity.handle }}</small>
|
||||||
</span>
|
</span>
|
||||||
</a>
|
</a>
|
||||||
|
|
|
@ -1,4 +1,5 @@
|
||||||
{% extends "base.html" %}
|
{% extends "base.html" %}
|
||||||
|
{% load emoji_tags %}
|
||||||
|
|
||||||
{% block title %}{{ identity }}{% endblock %}
|
{% block title %}{{ identity }}{% endblock %}
|
||||||
|
|
||||||
|
@ -40,7 +41,7 @@
|
||||||
{% endif %}
|
{% endif %}
|
||||||
{% endif %}
|
{% endif %}
|
||||||
|
|
||||||
{{ identity.name_or_handle }}
|
{{ identity.html_name_or_handle }}
|
||||||
<small>
|
<small>
|
||||||
@{{ identity.handle }}
|
@{{ identity.handle }}
|
||||||
<a title="Copy handle"
|
<a title="Copy handle"
|
||||||
|
|
|
@ -100,7 +100,9 @@ def test_linkify_mentions_remote(
|
||||||
|
|
||||||
|
|
||||||
@pytest.mark.django_db
|
@pytest.mark.django_db
|
||||||
def test_linkify_mentions_local(identity, identity2, remote_identity):
|
def test_linkify_mentions_local(
|
||||||
|
config_system, emoji_locals, identity, identity2, remote_identity
|
||||||
|
):
|
||||||
"""
|
"""
|
||||||
Tests that we can linkify post mentions properly for local use
|
Tests that we can linkify post mentions properly for local use
|
||||||
"""
|
"""
|
||||||
|
|
|
@ -2,6 +2,7 @@ import time
|
||||||
|
|
||||||
import pytest
|
import pytest
|
||||||
|
|
||||||
|
from activities.models import Emoji
|
||||||
from api.models import Application, Token
|
from api.models import Application, Token
|
||||||
from core.models import Config
|
from core.models import Config
|
||||||
from stator.runner import StatorModel, StatorRunner
|
from stator.runner import StatorModel, StatorRunner
|
||||||
|
@ -67,6 +68,16 @@ def config_system(keypair):
|
||||||
del Config.system
|
del Config.system
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.fixture
|
||||||
|
@pytest.mark.django_db
|
||||||
|
def emoji_locals():
|
||||||
|
Emoji.locals = Emoji.load_locals()
|
||||||
|
Emoji.__forced__ = True
|
||||||
|
yield Emoji.locals
|
||||||
|
Emoji.__forced__ = False
|
||||||
|
del Emoji.locals
|
||||||
|
|
||||||
|
|
||||||
@pytest.fixture
|
@pytest.fixture
|
||||||
@pytest.mark.django_db
|
@pytest.mark.django_db
|
||||||
def user() -> User:
|
def user() -> User:
|
||||||
|
|
|
@ -169,16 +169,20 @@ class Identity(StatorModel):
|
||||||
|
|
||||||
@property
|
@property
|
||||||
def safe_summary(self):
|
def safe_summary(self):
|
||||||
return sanitize_post(self.summary)
|
from activities.templatetags.emoji_tags import imageify_emojis
|
||||||
|
|
||||||
|
return imageify_emojis(sanitize_post(self.summary), self.domain)
|
||||||
|
|
||||||
@property
|
@property
|
||||||
def safe_metadata(self):
|
def safe_metadata(self):
|
||||||
|
from activities.templatetags.emoji_tags import imageify_emojis
|
||||||
|
|
||||||
if not self.metadata:
|
if not self.metadata:
|
||||||
return []
|
return []
|
||||||
return [
|
return [
|
||||||
{
|
{
|
||||||
"name": data["name"],
|
"name": data["name"],
|
||||||
"value": strip_html(data["value"]),
|
"value": imageify_emojis(strip_html(data["value"]), self.domain),
|
||||||
}
|
}
|
||||||
for data in self.metadata
|
for data in self.metadata
|
||||||
]
|
]
|
||||||
|
@ -240,6 +244,15 @@ class Identity(StatorModel):
|
||||||
def name_or_handle(self):
|
def name_or_handle(self):
|
||||||
return self.name or self.handle
|
return self.name or self.handle
|
||||||
|
|
||||||
|
@cached_property
|
||||||
|
def html_name_or_handle(self):
|
||||||
|
"""
|
||||||
|
Return the name_or_handle with any HTML substitutions made
|
||||||
|
"""
|
||||||
|
from activities.templatetags.emoji_tags import imageify_emojis
|
||||||
|
|
||||||
|
return imageify_emojis(self.name_or_handle, self.domain)
|
||||||
|
|
||||||
@property
|
@property
|
||||||
def handle(self):
|
def handle(self):
|
||||||
if self.username is None:
|
if self.username is None:
|
||||||
|
@ -303,6 +316,17 @@ class Identity(StatorModel):
|
||||||
}
|
}
|
||||||
return response
|
return response
|
||||||
|
|
||||||
|
def to_ap_tag(self):
|
||||||
|
"""
|
||||||
|
Return this Identity as an ActivityPub Tag
|
||||||
|
http://joinmastodon.org/ns#Mention
|
||||||
|
"""
|
||||||
|
return {
|
||||||
|
"href": self.actor_uri,
|
||||||
|
"name": "@" + self.handle,
|
||||||
|
"type": "Mention",
|
||||||
|
}
|
||||||
|
|
||||||
### ActivityPub (inbound) ###
|
### ActivityPub (inbound) ###
|
||||||
|
|
||||||
@classmethod
|
@classmethod
|
||||||
|
@ -470,7 +494,15 @@ class Identity(StatorModel):
|
||||||
### Mastodon Client API ###
|
### Mastodon Client API ###
|
||||||
|
|
||||||
def to_mastodon_json(self):
|
def to_mastodon_json(self):
|
||||||
|
from activities.models import Emoji
|
||||||
|
|
||||||
header_image = self.local_image_url()
|
header_image = self.local_image_url()
|
||||||
|
metadata_value_text = (
|
||||||
|
" ".join([m["value"] for m in self.metadata]) if self.metadata else ""
|
||||||
|
)
|
||||||
|
emojis = Emoji.emojis_from_content(
|
||||||
|
f"{self.name} {self.summary} {metadata_value_text}", self.domain
|
||||||
|
)
|
||||||
return {
|
return {
|
||||||
"id": self.pk,
|
"id": self.pk,
|
||||||
"username": self.username,
|
"username": self.username,
|
||||||
|
@ -491,7 +523,7 @@ class Identity(StatorModel):
|
||||||
if self.metadata
|
if self.metadata
|
||||||
else []
|
else []
|
||||||
),
|
),
|
||||||
"emojis": [],
|
"emojis": [emoji.to_mastodon_json() for emoji in emojis],
|
||||||
"bot": False,
|
"bot": False,
|
||||||
"group": False,
|
"group": False,
|
||||||
"discoverable": self.discoverable,
|
"discoverable": self.discoverable,
|
||||||
|
|
|
@ -85,6 +85,10 @@ class BasicSettings(AdminSettingsPage):
|
||||||
"title": "Unreviewed Hashtags Are Public",
|
"title": "Unreviewed Hashtags Are Public",
|
||||||
"help_text": "Public Hashtags may appear in Trending and have a Tags timeline",
|
"help_text": "Public Hashtags may appear in Trending and have a Tags timeline",
|
||||||
},
|
},
|
||||||
|
"emoji_unreviewed_are_public": {
|
||||||
|
"title": "Unreviewed Emoji Are Public",
|
||||||
|
"help_text": "Public Emoji may appear as images, instead of shortcodes",
|
||||||
|
},
|
||||||
}
|
}
|
||||||
|
|
||||||
layout = {
|
layout = {
|
||||||
|
@ -100,6 +104,7 @@ class BasicSettings(AdminSettingsPage):
|
||||||
"post_length",
|
"post_length",
|
||||||
"content_warning_text",
|
"content_warning_text",
|
||||||
"hashtag_unreviewed_are_public",
|
"hashtag_unreviewed_are_public",
|
||||||
|
"emoji_unreviewed_are_public",
|
||||||
],
|
],
|
||||||
"Identities": [
|
"Identities": [
|
||||||
"identity_max_per_user",
|
"identity_max_per_user",
|
||||||
|
|
Loading…
Reference in a new issue