Custom emoji support
This commit is contained in:
parent
5b025a8e45
commit
09ce33579a
17 changed files with 357 additions and 70 deletions
41
app/admin.py
41
app/admin.py
|
@ -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
|
||||
|
|
|
@ -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
|
||||
|
|
|
@ -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")
|
||||
|
|
11
app/main.py
11
app/main.py
|
@ -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,
|
||||
|
|
|
@ -140,6 +140,6 @@ nav.flexbox {
|
|||
float: right;
|
||||
}
|
||||
}
|
||||
.custom-emoji {
|
||||
.emoji, .custom-emoji {
|
||||
max-width: 25px;
|
||||
}
|
||||
|
|
|
@ -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
2
app/static/emoji/.gitignore
vendored
Normal file
|
@ -0,0 +1,2 @@
|
|||
*
|
||||
!.gitignore
|
2
app/static/twemoji/.gitignore
vendored
Normal file
2
app/static/twemoji/.gitignore
vendored
Normal file
|
@ -0,0 +1,2 @@
|
|||
*
|
||||
!.gitignore
|
|
@ -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
|
||||
|
|
|
@ -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
45
app/utils/emoji.py
Normal 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
|
Loading…
Add table
Add a link
Reference in a new issue