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