Custom emoji support

This commit is contained in:
Thomas Sileo 2022-06-27 20:55:44 +02:00
parent 5b025a8e45
commit 09ce33579a
17 changed files with 357 additions and 70 deletions

View file

@ -18,6 +18,7 @@ from app.actor import get_actors_metadata
from app.boxes import get_inbox_object_by_ap_id
from app.boxes import get_outbox_object_by_ap_id
from app.boxes import send_follow
from app.config import EMOJIS
from app.config import generate_csrf_token
from app.config import session_serializer
from app.config import verify_csrf_token
@ -25,6 +26,7 @@ from app.config import verify_password
from app.database import get_db
from app.lookup import lookup
from app.uploads import save_upload
from app.utils.emoji import EMOJIS_BY_NAME
def user_session_or_redirect(
@ -123,36 +125,11 @@ def admin_new(
(v.name, ap.VisibilityEnum.get_display_name(v))
for v in ap.VisibilityEnum
],
},
)
@router.get("/stream")
def stream(
request: Request,
db: Session = Depends(get_db),
) -> templates.TemplateResponse:
stream = (
db.query(models.InboxObject)
.filter(
models.InboxObject.ap_type.in_(["Note", "Article", "Video", "Announce"]),
models.InboxObject.is_hidden_from_stream.is_(False),
models.InboxObject.undone_by_inbox_object_id.is_(None),
)
.options(
# joinedload(models.InboxObject.relates_to_inbox_object),
joinedload(models.InboxObject.relates_to_outbox_object),
)
.order_by(models.InboxObject.ap_published_at.desc())
.limit(20)
.all()
)
return templates.render_template(
db,
request,
"admin_stream.html",
{
"stream": stream,
"emojis": EMOJIS.split(" "),
"custom_emojis": sorted(
[dat for name, dat in EMOJIS_BY_NAME.items()],
key=lambda obj: obj["name"],
),
},
)
@ -452,7 +429,7 @@ def admin_actions_unpin(
@router.post("/actions/new")
def admin_actions_new(
request: Request,
files: list[UploadFile],
files: list[UploadFile] = [],
content: str = Form(),
redirect_url: str = Form(),
in_reply_to: str | None = Form(None),
@ -501,7 +478,7 @@ def login_validation(
if not verify_password(password):
raise HTTPException(status_code=401)
resp = RedirectResponse("/admin", status_code=302)
resp = RedirectResponse("/admin/inbox", status_code=302)
resp.set_cookie("session", session_serializer.dumps({"is_logged_in": True})) # type: ignore # noqa: E501
return resp

View file

@ -341,7 +341,7 @@ def _compute_recipients(db: Session, ap_object: ap.RawObject) -> set[str]:
db.query(models.Actor).filter(models.Actor.ap_id == r).one_or_none()
)
if known_actor:
recipients.add(known_actor.shared_inbox_url or actor.inbox_url)
recipients.add(known_actor.shared_inbox_url or known_actor.inbox_url)
continue
# Fetch the object

View file

@ -10,6 +10,8 @@ from fastapi import Request
from itsdangerous import TimedSerializer
from itsdangerous import TimestampSigner
from app.utils.emoji import _load_emojis
ROOT_DIR = Path().parent.resolve()
_CONFIG_FILE = os.getenv("MICROBLOGPUB_CONFIG_FILE", "me.toml")
@ -76,6 +78,11 @@ SQLALCHEMY_DATABASE_URL = CONFIG.sqlalchemy_database_url or f"sqlite:///{DB_PATH
KEY_PATH = (
(ROOT_DIR / CONFIG.key_path) if CONFIG.key_path else ROOT_DIR / "data" / "key.pem"
)
EMOJIS = "😺 😸 😹 😻 😼 😽 🙀 😿 😾"
# Emoji template for the FE
EMOJI_TPL = '<img src="/static/twemoji/{filename}.svg" alt="{raw}" class="emoji">'
_load_emojis(ROOT_DIR, BASE_URL)
session_serializer = TimedSerializer(CONFIG.secret, salt="microblogpub.login")

View file

@ -52,6 +52,7 @@ from app.config import verify_csrf_token
from app.database import get_db
from app.templates import is_current_user_admin
from app.uploads import UPLOAD_DIR
from app.utils.emoji import EMOJIS_BY_NAME
from app.webfinger import get_remote_follow_template
# TODO(ts):
@ -520,6 +521,16 @@ def tag_by_name(
)
@app.get("/e/{name}")
def emoji_by_name(name: str) -> ActivityPubResponse:
try:
emoji = EMOJIS_BY_NAME[f":{name}:"]
except KeyError:
raise HTTPException(status_code=404)
return ActivityPubResponse({"@context": ap.AS_CTX, **emoji})
@app.post("/inbox")
async def inbox(
request: Request,

View file

@ -140,6 +140,6 @@ nav.flexbox {
float: right;
}
}
.custom-emoji {
.emoji, .custom-emoji {
max-width: 25px;
}

View file

@ -8,6 +8,7 @@ from app import webfinger
from app.actor import Actor
from app.actor import fetch_actor
from app.config import BASE_URL
from app.utils import emoji
def _set_a_attrs(attrs, new=False):
@ -78,5 +79,10 @@ def markdownify(
if mentionify:
content, mention_tags, mentioned_actors = _mentionify(db, content)
tags.extend(mention_tags)
# Handle custom emoji
tags.extend(emoji.tags(content))
content = markdown(content, extensions=["mdx_linkify"])
return content, tags, mentioned_actors

2
app/static/emoji/.gitignore vendored Normal file
View file

@ -0,0 +1,2 @@
*
!.gitignore

2
app/static/twemoji/.gitignore vendored Normal file
View file

@ -0,0 +1,2 @@
*
!.gitignore

View file

@ -6,6 +6,7 @@ from typing import Any
from urllib.parse import urlparse
import bleach
import emoji
import html2text
import timeago # type: ignore
from bs4 import BeautifulSoup # type: ignore
@ -16,6 +17,7 @@ from sqlalchemy.orm import Session
from starlette.templating import _TemplateResponse as TemplateResponse
from app import activitypub as ap
from app import config
from app import models
from app.actor import LOCAL_ACTOR
from app.ap_object import Attachment
@ -171,14 +173,16 @@ def _update_inline_imgs(content):
def _clean_html(html: str, note: Object) -> str:
try:
return _replace_custom_emojis(
bleach.clean(
_update_inline_imgs(highlight(html)),
tags=ALLOWED_TAGS,
attributes=ALLOWED_ATTRIBUTES,
strip=True,
),
note,
return _emojify(
_replace_custom_emojis(
bleach.clean(
_update_inline_imgs(highlight(html)),
tags=ALLOWED_TAGS,
attributes=ALLOWED_ATTRIBUTES,
strip=True,
),
note,
)
)
except Exception:
raise
@ -229,11 +233,24 @@ def _html2text(content: str) -> str:
return H2T.handle(content)
def _replace_emoji(u, data):
filename = hex(ord(u))[2:]
return config.EMOJI_TPL.format(filename=filename, raw=u)
def _emojify(text: str):
return emoji.replace_emoji(
text,
replace=_replace_emoji,
)
_templates.env.filters["domain"] = _filter_domain
_templates.env.filters["media_proxy_url"] = _media_proxy_url
_templates.env.filters["clean_html"] = _clean_html
_templates.env.filters["timeago"] = _timeago
_templates.env.filters["format_date"] = _format_date
_templates.env.filters["has_media_type"] = _has_media_type
_templates.env.filters["pluralize"] = _pluralize
_templates.env.filters["html2text"] = _html2text
_templates.env.filters["emojify"] = _emojify
_templates.env.filters["pluralize"] = _pluralize

View file

@ -17,6 +17,13 @@
{% endfor %}
</select>
</p>
{% for emoji in emojis %}
<span class="ji">{{ emoji | emojify | safe }}</span>
{% endfor %}
{% for emoji in custom_emojis %}
<span class="ji"><img src="{{ emoji.icon.url }}" alt="{{ emoji.name }}" title="{{ emoji.name }}" class="custom-emoji"></span>
{% endfor %}
<textarea name="content" rows="10" cols="50" autofocus="autofocus" designMode="on" style="font-size:1.2em;width:95%;">{{ content }}</textarea>
<input type="hidden" name="in_reply_to" value="{{ request.query_params.in_reply_to }}">
<p>
@ -26,5 +33,38 @@
<input type="submit" value="Publish">
</p>
</form>
<script>
// The new post textarea
var ta = document.getElementsByTagName("textarea")[0];
// Helper for inserting text (emojis) in the textarea
function insertAtCursor (textToInsert) {
ta.focus();
const isSuccess = document.execCommand("insertText", false, textToInsert);
// Firefox (non-standard method)
if (!isSuccess) {
// Credits to https://www.everythingfrontend.com/posts/insert-text-into-textarea-at-cursor-position.html
// get current text of the input
const value = ta.value;
// save selection start and end position
const start = ta.selectionStart;
const end = ta.selectionEnd;
// update the value with our text inserted
ta.value = value.slice(0, start) + textToInsert + value.slice(end);
// update cursor to be at the end of insertion
ta.selectionStart = ta.selectionEnd = start + textToInsert.length;
}
}
// Emoji click callback func
var ji = function (ev) {
insertAtCursor(ev.target.attributes.alt.value + " ");
ta.focus()
//console.log(document.execCommand('insertText', false /*no UI*/, ev.target.attributes.alt.value));
}
// Enable the click for each emojis
var items = document.getElementsByClassName("ji")
for (var i = 0; i < items.length; i++) {
items[i].addEventListener('click', ji);
}
</script>
{% endblock %}

45
app/utils/emoji.py Normal file
View file

@ -0,0 +1,45 @@
import mimetypes
import re
import typing
from pathlib import Path
if typing.TYPE_CHECKING:
from app.activitypub import RawObject
EMOJI_REGEX = re.compile(r"(:[\d\w]+:)")
EMOJIS: dict[str, "RawObject"] = {}
EMOJIS_BY_NAME: dict[str, "RawObject"] = {}
def _load_emojis(root_dir: Path, base_url: str) -> None:
if EMOJIS:
return
for emoji in (root_dir / "app" / "static" / "emoji").iterdir():
mt = mimetypes.guess_type(emoji.name)[0]
if mt and mt.startswith("image/"):
name = emoji.name.split(".")[0]
ap_emoji: "RawObject" = {
"type": "Emoji",
"name": f":{name}:",
"updated": "1970-01-01T00:00:00Z", # XXX: we don't track date
"id": f"{base_url}/e/{name}",
"icon": {
"mediaType": mt,
"type": "Image",
"url": f"{base_url}/static/emoji/{emoji.name}",
},
}
EMOJIS[emoji.name] = ap_emoji
EMOJIS_BY_NAME[ap_emoji["name"]] = ap_emoji
def tags(content: str) -> list["RawObject"]:
tags = []
added = set()
for e in re.findall(EMOJI_REGEX, content):
if e not in added and e in EMOJIS_BY_NAME:
tags.append(EMOJIS_BY_NAME[e])
added.add(e)
return tags