git / moonbite-network / forum_emoji.py · raw
"""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-hostedTwemoji set in static/emoji/ (CC-BY 4.0, see static/emoji/NOTICE.txt), soevery 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, andonly ever inserts <img> tags whose src is built from a file that exists."""from __future__ import annotationsimport osimport 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'<img class="fx-emoji" src="{_URL}{src}" alt="{alt}"{t} ' f'draggable="false" decoding="async">')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