"""Emoji for the community forum only (the rest of the site stays emoji-free).
Unicode emoji in posts, titles and excerpts are drawn with the self-hosted
Twemoji set in static/emoji/ (CC-BY 4.0, see static/emoji/NOTICE.txt), so
every visitor sees the same artwork and no third-party host is contacted.
Discord-style shortcodes such as :rocket: work too, plus MoonBite's own
:moonbite:. Everything here runs on text that is already HTML-escaped, and
only ever inserts
tags whose src is built from a file that exists.
"""
from __future__ import annotations
import os
import re
_DIR = os.path.join(os.path.dirname(os.path.abspath(__file__)), "static", "emoji")
_URL = "/static/emoji/"
try:
_NAMES = frozenset(f[:-4] for f in os.listdir(_DIR) if f.endswith(".svg"))
except OSError: # emoji folder missing: fall back to the OS's own emoji font
_NAMES = frozenset()
_MAXLEN = max((n.count("-") + 1 for n in _NAMES), default=1)
# MoonBite's own emoji (not Twemoji). shortcode -> file under static/emoji/custom/
CUSTOM = {"moonbite": "custom/moonbite.svg"}
# The picker's set, grouped like Discord's. (shortcode, emoji). Any other
# emoji still renders when typed or pasted; this is just what the picker offers.
PICKER = [
("People", [
("grinning", "๐"), ("smile", "๐"), ("joy", "๐"), ("rofl", "๐คฃ"), ("wink", "๐"),
("blush", "๐"), ("heart_eyes", "๐"), ("star_struck", "๐คฉ"), ("sunglasses", "๐"),
("thinking", "๐ค"), ("raised_eyebrow", "๐คจ"), ("neutral_face", "๐"), ("rolling_eyes", "๐"),
("smirk", "๐"), ("relieved", "๐"), ("sleeping", "๐ด"), ("exploding_head", "๐คฏ"),
("partying_face", "๐ฅณ"), ("nerd", "๐ค"), ("confused", "๐"), ("worried", "๐"),
("cry", "๐ข"), ("sob", "๐ญ"), ("scream", "๐ฑ"), ("angry", "๐ "), ("skull", "๐"),
("clown", "๐คก"), ("ghost", "๐ป"), ("alien", "๐ฝ"), ("robot", "๐ค"), ("eyes", "๐"),
("salute", "๐ซก"), ("melting_face", "๐ซ "), ("shushing_face", "๐คซ"), ("money_mouth", "๐ค"),
]),
("Gestures", [
("thumbsup", "๐"), ("thumbsdown", "๐"), ("ok_hand", "๐"), ("clap", "๐"), ("wave", "๐"),
("raised_hands", "๐"), ("pray", "๐"), ("handshake", "๐ค"), ("muscle", "๐ช"), ("fist", "๐"),
("v", "โ๏ธ"), ("crossed_fingers", "๐ค"), ("point_up", "โ๏ธ"), ("point_right", "๐"),
("point_down", "๐"), ("call_me", "๐ค"), ("writing_hand", "โ๏ธ"), ("brain", "๐ง "),
]),
("Hearts", [
("heart", "โค๏ธ"), ("orange_heart", "๐งก"), ("yellow_heart", "๐"), ("green_heart", "๐"),
("blue_heart", "๐"), ("purple_heart", "๐"), ("black_heart", "๐ค"), ("white_heart", "๐ค"),
("broken_heart", "๐"), ("sparkling_heart", "๐"), ("heart_on_fire", "โค๏ธโ๐ฅ"), ("100", "๐ฏ"),
]),
("Space", [
("rocket", "๐"), ("moon", "๐"), ("full_moon", "๐"), ("new_moon", "๐"), ("crescent_moon", "๐"),
("first_quarter_moon", "๐"), ("waxing_crescent_moon", "๐"), ("earth", "๐"), ("star", "โญ"),
("star2", "๐"), ("sparkles", "โจ"), ("comet", "โ๏ธ"), ("milky_way", "๐"), ("sunny", "โ๏ธ"),
("zap", "โก"), ("fire", "๐ฅ"), ("rainbow", "๐"), ("telescope", "๐ญ"), ("satellite", "๐ฐ๏ธ"),
("flying_saucer", "๐ธ"), ("astronaut", "๐งโ๐"),
]),
("Mining & tech", [
("pick", "โ๏ธ"), ("hammer_and_pick", "โ๏ธ"), ("hammer", "๐จ"), ("wrench", "๐ง"), ("gear", "โ๏ธ"),
("computer", "๐ป"), ("desktop", "๐ฅ๏ธ"), ("keyboard", "โจ๏ธ"), ("battery", "๐"), ("electric_plug", "๐"),
("bulb", "๐ก"), ("chart_up", "๐"), ("chart_down", "๐"), ("bar_chart", "๐"), ("coin", "๐ช"),
("moneybag", "๐ฐ"), ("gem", "๐"), ("key", "๐"), ("lock", "๐"), ("unlock", "๐"),
("shield", "๐ก๏ธ"), ("link", "๐"), ("chains", "โ๏ธ"), ("package", "๐ฆ"), ("hourglass", "โณ"),
("stopwatch", "โฑ๏ธ"), ("bell", "๐"), ("mag", "๐"), ("bug", "๐"), ("test_tube", "๐งช"),
("satellite_antenna", "๐ก"), ("globe", "๐"),
]),
("Nature & food", [
("whale", "๐"), ("shark", "๐ฆ"), ("dog", "๐ถ"), ("cat", "๐ฑ"), ("fox", "๐ฆ"), ("owl", "๐ฆ"),
("bear", "๐ป"), ("bull", "๐"), ("frog", "๐ธ"), ("unicorn", "๐ฆ"), ("seedling", "๐ฑ"),
("evergreen_tree", "๐ฒ"), ("cactus", "๐ต"), ("mushroom", "๐"), ("pizza", "๐"), ("cookie", "๐ช"),
("doughnut", "๐ฉ"), ("coffee", "โ"), ("beer", "๐บ"), ("cheese", "๐ง"), ("popcorn", "๐ฟ"),
]),
("Symbols", [
("white_check_mark", "โ
"), ("x", "โ"), ("warning", "โ ๏ธ"), ("no_entry", "โ"), ("question", "โ"),
("exclamation", "โ"), ("tada", "๐"), ("confetti_ball", "๐"), ("trophy", "๐"), ("medal", "๐
"),
("crown", "๐"), ("dart", "๐ฏ"), ("game_die", "๐ฒ"), ("pushpin", "๐"), ("memo", "๐"),
("books", "๐"), ("speech_balloon", "๐ฌ"), ("loudspeaker", "๐ข"), ("arrow_up", "โฌ๏ธ"),
("arrow_down", "โฌ๏ธ"), ("arrows_counterclockwise", "๐"), ("new", "๐"), ("free", "๐"), ("sos", "๐"),
]),
]
# Shortcode -> emoji, from the picker plus common aliases people type.
SHORTCODES = {name: ch for _, items in PICKER for name, ch in items}
SHORTCODES.update({
"+1": "๐", "-1": "๐", "like": "๐", "joy_cat": "๐น", "laughing": "๐", "lol": "๐",
"pickaxe": "โ๏ธ", "ok": "๐", "check": "โ
", "money": "๐ฐ", "diamond": "๐", "gm": "๐
",
"wagmi": "๐", "lightning": "โก", "flame": "๐ฅ", "party": "๐", "hundred": "๐ฏ",
})
_SHORT_RE = re.compile(r":([a-z0-9_+\-]{1,32}):")
_FE0F, _ZWJ = "๏ธ", "โ"
def key(seq: str) -> str:
"""Twemoji filename for an emoji sequence: hex codepoints joined by '-',
with U+FE0F dropped unless the sequence has a zero-width joiner."""
if _ZWJ not in seq:
seq = seq.replace(_FE0F, "")
return "-".join(f"{ord(c):x}" for c in seq)
def _img(src: str, alt: str, title: str = "") -> str:
t = f' title=":{title}:"' if title else ""
return (f'
')
def _candidate(text: str, i: int) -> bool:
o = ord(text[i])
# Below U+2190 only keycaps and text symbols explicitly asking for emoji
# presentation (FE0F), so plain digits, '#', (c) and (r) stay text.
return o >= 0x2190 or (i + 1 < len(text) and text[i + 1] in (_FE0F, "โฃ"))
def _scan(text: str):
"""Yield (is_emoji, piece) runs over `text`, longest match first."""
i, n, start = 0, len(text), 0
while i < n:
if _candidate(text, i):
for length in range(min(_MAXLEN + 2, n - i), 0, -1):
seq = text[i:i + length]
if key(seq) in _NAMES:
if start < i:
yield False, text[start:i]
yield True, seq
i += length
start = i
break
else:
i += 1
else:
i += 1
if start < n:
yield False, text[start:]
def _shortcodes(escaped: str) -> str:
def sub(m):
name = m.group(1)
if name in CUSTOM:
return _img(CUSTOM[name], f":{name}:", name)
ch = SHORTCODES.get(name)
if ch and key(ch) in _NAMES:
return _img(key(ch) + ".svg", ch, name)
return m.group(0)
return _SHORT_RE.sub(sub, escaped)
def emojify(escaped: str) -> str:
"""Replace emoji and known :shortcodes: in already-escaped text with
Twemoji images. Text that isn't an emoji passes through untouched."""
if not _NAMES:
return escaped
out = []
for is_emoji, piece in _scan(escaped):
if is_emoji:
out.append(_img(key(piece) + ".svg", piece))
else:
out.append(_shortcodes(piece))
return "".join(out)
def emoji_only(text: str, limit: int = 8) -> bool:
"""True when `text` is nothing but 1..limit emoji (Discord-style jumbo)."""
text = _SHORT_RE.sub(lambda m: "\x00" if (m.group(1) in CUSTOM or m.group(1) in SHORTCODES) else m.group(0), text.strip())
count = text.count("\x00")
rest = []
for is_emoji, piece in _scan(text.replace("\x00", "")):
if is_emoji:
count += 1
else:
rest.append(piece)
return 0 < count <= limit and "".join(rest).strip() == ""
def picker_data() -> list:
"""Picker groups for the composer: [{"name", "items": [{s, c, k}]}]."""
groups = [{"name": "MoonBite", "items": [{"s": n, "c": f":{n}:", "k": f} for n, f in CUSTOM.items()]}]
for name, items in PICKER:
groups.append({"name": name, "items": [
{"s": s, "c": ch, "k": key(ch) + ".svg"} for s, ch in items if key(ch) in _NAMES
]})
return groups