"""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'{alt}') 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