C411 - Emojis Plus

Ajoute des packs d'emojis dedies par canal au selecteur d'emojis des messages C411, avec preselection automatique et barre de recherche transverse aux packs. Packs disponibles : Audio, Video, Ebook, Application, Jeux video, XXX (NSFW).

Voor het installeren van scripts heb je een extensie nodig, zoals Tampermonkey, Greasemonkey of Violentmonkey.

Voor het installeren van scripts heb je een extensie nodig, zoals {tampermonkey_link:Tampermonkey}.

Voor het installeren van scripts heb je een extensie nodig, zoals Tampermonkey of Violentmonkey.

Voor het installeren van scripts heb je een extensie nodig, zoals Tampermonkey of Userscripts.

Voor het installeren van scripts heb je een extensie nodig, zoals {tampermonkey_link:Tampermonkey}.

Voor het installeren van scripts heb je een gebruikersscriptbeheerder nodig.

(Ik heb al een user script manager, laat me het downloaden!)

Voor het installeren van gebruikersstijlen heb je een extensie nodig, zoals {stylus_link:Stylus}.

Voor het installeren van gebruikersstijlen heb je een extensie nodig, zoals {stylus_link:Stylus}.

Voor het installeren van gebruikersstijlen heb je een extensie nodig, zoals {stylus_link:Stylus}.

Voor het installeren van gebruikersstijlen heb je een gebruikersstijlbeheerder nodig.

Voor het installeren van gebruikersstijlen heb je een gebruikersstijlbeheerder nodig.

Voor het installeren van gebruikersstijlen heb je een gebruikersstijlbeheerder nodig.

(Ik heb al een beheerder - laat me doorgaan met de installatie!)

// ==UserScript==
// @name         C411 - Emojis Plus
// @namespace    https://c411.org/
// @version      2026.05.24-1
// @description  Ajoute des packs d'emojis dedies par canal au selecteur d'emojis des messages C411, avec preselection automatique et barre de recherche transverse aux packs. Packs disponibles : Audio, Video, Ebook, Application, Jeux video, XXX (NSFW).
// @author       CuOgmy
// @contributor  Cbo (packs Video, Ebook, Application, Jeux video, XXX)
// @license      MIT
// @homepageURL  https://greasyfork.org/fr/scripts/577498-c411-emojis-audio
// @supportURL   https://greasyfork.org/fr/scripts/577498-c411-emojis-audio/feedback
// @match        https://c411.org/*
// @icon         https://c411.org/favicon.ico
// @grant        none
// @run-at       document-end
// @compatible   chrome Tampermonkey
// @compatible   chrome Violentmonkey
// @compatible   firefox Tampermonkey
// @compatible   firefox Violentmonkey
// @compatible   edge Tampermonkey
// ==/UserScript==

(function () {
    'use strict';

    // ─── 00-config.js ───
    // ═══════════════════════════════════════════════════════════════════════
    // SECTION : CONFIGURATION
    // ═══════════════════════════════════════════════════════════════════════
    //
    // Packs d'émojis par canal. Ordre des émojis pensé pour la grille 8
    // colonnes du picker natif : un multiple de 8 remplit pile la grille, un
    // nombre quelconque laisse une fin de ligne partielle (sans impact).
    //
    // Contributions communautaires :
    //   - Packs Vidéo, Ebook, Application, Jeux vidéo, XXX (NSFW) proposés
    //     par Cbo (membre C411).

    // Audio (canal 13). Groupé par thème : notes/voix/écoute, instruments
    // à cordes/vent, percussion/médias, contrôles de lecture, danse/vibes.
    const AUDIO_EMOJIS = [
        '🎵', '🎶', '🎼', '🎤', '🎙️', '🎧', '🔊', '🔉',
        '🎸', '🎹', '🥁', '🎺', '🎷', '🎻', '🪕', '🪗',
        '🪘', '📀', '💿', '📻', '🎚️', '🎛️', '▶️', '⏸️',
        '⏯️', '🕺', '💃', '🤘', '😎', '🔥', '⚡', '✨'
    ];

    // Vidéo (canal 12). Clap/caméra/projection, lecture, popcorn.
    const VIDEO_EMOJIS = [
        '🎬', '🎥', '🎞️', '📽️', '📹', '📺', '🍿',
        '🎦', '▶️', '⏸️', '⏹️', '⏮️', '⏭️', '🔊'
    ];

    // Ebook (canal 14). Livres, lecture, écriture, signets.
    const EBOOK_EMOJIS = [
        '📚', '📖', '📕', '📗', '📘', '📙', '📓', '📔',
        '📒', '📑', '📰', '🗞️', '👓', '🕶️', '🔍', '🔎',
        '✏️', '✒️', '🖊️', '🖋️', '🖌️', '📝', '📌', '📍',
        '📎', '🔖', '💡', '🧠', '🎓', '🤓', '🖥️'
    ];

    // Application (canal 15). Matériel, périphériques, dev, sécurité.
    const APPLI_EMOJIS = [
        '💻', '🖥️', '⌨️', '🖱️', '🖨️', '💾', '💿', '📀',
        '📱', '📲', '☎️', '📞', '📟', '📠', '📧', '💬',
        '🔌', '🔋', '🖲️', '🕹️', '🎮', '⚙️', '🔧', '🔨',
        '🐍', '☕', '🔐', '🔒', '🗝️', '🔑', '📊'
    ];

    // Jeux vidéo (canal 16). Contrôleurs, combat, butin, célébrations.
    const GAMES_EMOJIS = [
        '🎮', '🕹️', '👾', '🎯', '🎲', '🃏', '🏆', '🥇',
        '💣', '🔫', '🗡️', '🛡️', '⚔️', '🔥', '⚡', '💥',
        '💎', '💰', '🪙', '🎁', '📦', '🗺️', '🧭', '🔓',
        '🏅', '🎪', '🌟', '✨', '💫', '🎆', '🎊', '🎉'
    ];

    // XXX / NSFW (canal 20). Conventionnel — l'usage Internet a tranché.
    const XXX_EMOJIS = [
        '🍆', '🍌', '🥒', '🥕', '🍑', '🍒', '🍄', '💦',
        '💧', '🌊', '🚿', '🛁', '🌺', '🌹', '👅', '💋',
        '🔥', '🌶️', '🥵', '😏', '😈', '🐰', '🦶', '🌷'
    ];

    // Packs d'émojis par identifiant de canal C411 (paramètre `?channel=` de
    // l'URL `/messages`). Pour ajouter un pack à un autre canal, ajouter une
    // entrée : { '<channelId>': { name: '<libellé onglet>', emojis: [...] } }.
    const CHANNEL_EMOJI_PACKS = {
        '12': { name: 'Vidéo',       emojis: VIDEO_EMOJIS },
        '13': { name: 'Audio',       emojis: AUDIO_EMOJIS },
        '14': { name: 'Ebook',       emojis: EBOOK_EMOJIS },
        '15': { name: 'Application', emojis: APPLI_EMOJIS },
        '16': { name: 'Jeux vidéo',  emojis: GAMES_EMOJIS },
        '20': { name: 'XXX (NSFW)',  emojis: XXX_EMOJIS }
    };

    // Mots-clés FR par émoji, pour la barre de recherche.
    //
    // Cette map est la SOURCE DE VERITE de la recherche : tout émoji présent
    // ici est trouvable, qu'il soit ou non rangé dans un pack.
    //   - Un émoji des packs sans entrée ici -> présent dans l'onglet, mais
    //     pas trouvable par mot-clé.
    //   - Un émoji sans pack mais avec entrée ici -> trouvable par recherche
    //     seulement (cf. section "Emojis bonus" plus bas).
    //
    // Match : insensible à la casse et aux accents (cf. `normalize` / 10-utils),
    // on cherche `query` en sous-chaîne dans n'importe lequel des mots-clés.
    // Donc inutile d'écrire à la fois "guitare" et "guitares" - "guitar" suffit
    // pour matcher les deux.
    const EMOJI_KEYWORDS = {
        // Audio — notes, voix, écoute
        '🎵': ['note', 'musique', 'son'],
        '🎶': ['notes', 'musique', 'melodie', 'partition'],
        '🎼': ['partition', 'musique', 'portee'],
        '🎤': ['micro', 'chant', 'karaoke', 'voix'],
        '🎙️': ['micro', 'studio', 'podcast', 'radio'],
        '🎧': ['casque', 'ecouteurs', 'audio', 'musique'],
        '🔊': ['son', 'haut parleur', 'volume', 'fort'],
        '🔉': ['son', 'haut parleur', 'volume', 'moyen'],
        // Audio — instruments
        '🎸': ['guitar', 'rock', 'instrument'],
        '🎹': ['piano', 'clavier', 'instrument'],
        '🥁': ['batterie', 'tambour', 'drum', 'percussion', 'instrument'],
        '🎺': ['trompette', 'cuivre', 'jazz', 'instrument'],
        '🎷': ['saxo', 'saxophone', 'jazz', 'instrument'],
        '🎻': ['violon', 'cordes', 'classique', 'instrument'],
        '🪕': ['banjo', 'cordes', 'country', 'instrument'],
        '🪗': ['accordeon', 'soufflet', 'instrument'],
        '🪘': ['djembe', 'tambour', 'percussion', 'instrument'],
        // Audio — supports
        '📀': ['dvd', 'disque', 'support'],
        '💿': ['cd', 'disque', 'support'],
        '📻': ['radio', 'transistor'],
        '🎚️': ['fader', 'mixage', 'curseur'],
        '🎛️': ['console', 'mixage', 'potards'],
        // Lecture (transversal audio/video)
        '▶️': ['play', 'lecture', 'lancer'],
        '⏸️': ['pause', 'arret', 'suspendre'],
        '⏯️': ['play pause', 'lecture pause'],
        '⏹️': ['stop', 'arret'],
        '⏮️': ['precedent', 'retour', 'piste'],
        '⏭️': ['suivant', 'avance', 'piste'],
        // Audio — vibes
        '🕺': ['danse', 'homme', 'disco'],
        '💃': ['danse', 'femme', 'flamenco'],
        '🤘': ['rock', 'metal', 'cornes', 'main'],
        '😎': ['cool', 'lunettes', 'classe'],

        // Vidéo
        '🎬': ['clap', 'cinema', 'film', 'action'],
        '🎥': ['camera', 'cinema', 'film'],
        '🎞️': ['pellicule', 'film', 'bobine'],
        '📽️': ['projecteur', 'cinema', 'film'],
        '📹': ['camescope', 'video', 'enregistrement'],
        '📺': ['television', 'tv', 'ecran'],
        '🍿': ['popcorn', 'cinema', 'maïs', 'snack'],
        '🎦': ['cinema', 'salle', 'sigle'],

        // Ebook — livres
        '📚': ['livres', 'bibliotheque', 'pile'],
        '📖': ['livre', 'ouvert', 'lecture'],
        '📕': ['livre', 'rouge', 'ferme'],
        '📗': ['livre', 'vert', 'ferme'],
        '📘': ['livre', 'bleu', 'ferme'],
        '📙': ['livre', 'orange', 'ferme'],
        '📓': ['cahier', 'carnet', 'notes'],
        '📔': ['carnet', 'decore', 'journal'],
        '📒': ['cahier', 'registre', 'comptable'],
        '📑': ['onglets', 'marque page', 'signets'],
        '📰': ['journal', 'presse', 'news'],
        '🗞️': ['journal', 'roule', 'presse'],
        '👓': ['lunettes', 'vue', 'lecture'],
        '🕶️': ['lunettes', 'soleil', 'cool'],
        '🔍': ['loupe', 'recherche', 'zoom'],
        '🔎': ['loupe', 'recherche', 'zoom'],
        // Ebook — écriture
        '✏️': ['crayon', 'ecriture', 'taille'],
        '✒️': ['plume', 'stylo', 'noir'],
        '🖊️': ['stylo', 'bille', 'ecriture'],
        '🖋️': ['stylo', 'plume', 'ecriture'],
        '🖌️': ['pinceau', 'peinture', 'art'],
        '📝': ['memo', 'note', 'ecrire'],
        '📌': ['punaise', 'epingle', 'fixer'],
        '📍': ['epingle', 'localisation', 'lieu'],
        '📎': ['trombone', 'attache', 'piece jointe'],
        '🔖': ['marque page', 'signet'],
        '💡': ['ampoule', 'idee', 'lumiere'],
        '🧠': ['cerveau', 'reflexion', 'intelligence'],
        '🎓': ['diplome', 'graduation', 'etudiant'],
        '🤓': ['nerd', 'geek', 'lunettes'],

        // Application — matériel
        '💻': ['ordinateur', 'portable', 'laptop'],
        '🖥️': ['ordinateur', 'desktop', 'ecran'],
        '⌨️': ['clavier', 'keyboard', 'frappe'],
        '🖱️': ['souris', 'mouse', 'curseur'],
        '🖨️': ['imprimante', 'impression', 'print'],
        '💾': ['disquette', 'sauvegarde', 'save', 'floppy'],
        '📱': ['telephone', 'smartphone', 'mobile'],
        '📲': ['telephone', 'appel', 'fleche'],
        '☎️': ['telephone', 'fixe', 'classique'],
        '📞': ['telephone', 'combine', 'appel'],
        '📟': ['pager', 'bipeur', 'beeper'],
        '📠': ['fax', 'telecopieur'],
        '📧': ['mail', 'email', 'courriel', 'enveloppe'],
        '💬': ['bulle', 'chat', 'message'],
        '🔌': ['prise', 'fiche', 'electrique', 'branchement'],
        '🔋': ['batterie', 'pile', 'energie'],
        '🖲️': ['trackball', 'boule', 'pointeur'],
        '🕹️': ['joystick', 'manette', 'arcade'],
        '🎮': ['manette', 'gamepad', 'jeux'],
        '⚙️': ['engrenage', 'parametres', 'reglages', 'config'],
        '🔧': ['cle', 'outil', 'reglage', 'mecanique'],
        '🔨': ['marteau', 'outil', 'frapper'],
        '🐍': ['serpent', 'python'],
        '☕': ['cafe', 'tasse', 'boisson', 'java'],
        '🔐': ['cadenas', 'verrou', 'cle', 'securite'],
        '🔒': ['cadenas', 'verrou', 'ferme', 'securite'],
        '🗝️': ['cle', 'ancienne', 'secret'],
        '🔑': ['cle', 'serrure', 'mot de passe'],
        '📊': ['graphique', 'barres', 'stats', 'data'],

        // Jeux vidéo
        '👾': ['alien', 'monstre', 'invader', 'pixel'],
        '🎯': ['cible', 'fleche', 'bullseye', 'objectif'],
        '🎲': ['des', 'dice', 'hasard'],
        '🃏': ['joker', 'carte', 'jeu'],
        '🏆': ['trophee', 'coupe', 'victoire'],
        '🥇': ['medaille', 'or', 'premier', 'gagnant'],
        '💣': ['bombe', 'explosif'],
        '🔫': ['pistolet', 'arme', 'tir'],
        '🗡️': ['epee', 'dague', 'arme'],
        '🛡️': ['bouclier', 'defense', 'protection'],
        '⚔️': ['epees', 'combat', 'duel', 'croisees'],
        '💥': ['explosion', 'boum', 'impact'],
        '💎': ['diamant', 'gemme', 'precieux'],
        '💰': ['sac', 'argent', 'fortune', 'gain'],
        '🪙': ['piece', 'monnaie', 'coin'],
        '🎁': ['cadeau', 'paquet', 'present'],
        '📦': ['carton', 'colis', 'boite', 'paquet'],
        '🗺️': ['carte', 'map', 'monde'],
        '🧭': ['boussole', 'direction', 'nord'],
        '🔓': ['cadenas', 'ouvert', 'deverrouille'],
        '🏅': ['medaille', 'sport', 'distinction'],
        '🎪': ['chapiteau', 'cirque', 'tente'],
        '🌟': ['etoile', 'brillante'],
        '✨': ['etincelles', 'magie', 'brillance', 'sparkles'],
        '💫': ['etoile', 'filante', 'vertige'],
        '🎆': ['feux artifice', 'feu d artifice', 'celebration'],
        '🎊': ['confettis', 'fete', 'celebration'],
        '🎉': ['fete', 'cotillon', 'party', 'celebration'],

        // Transversal Audio / Jeux
        '🔥': ['feu', 'flamme', 'hot', 'chaud'],
        '⚡': ['eclair', 'foudre', 'energie', 'electrique'],

        // XXX (NSFW)
        '🍆': ['aubergine'],
        '🍌': ['banane'],
        '🥒': ['concombre'],
        '🥕': ['carotte'],
        '🍑': ['peche', 'fesses'],
        '🍒': ['cerises', 'fruit'],
        '🍄': ['champignon'],
        '💦': ['gouttes', 'eau', 'spray', 'sueur'],
        '💧': ['goutte', 'eau', 'larme'],
        '🌊': ['vague', 'mer', 'ocean'],
        '🚿': ['douche', 'eau'],
        '🛁': ['baignoire', 'bain'],
        '🌺': ['fleur', 'hibiscus'],
        '🌹': ['rose', 'fleur', 'amour'],
        '👅': ['langue', 'gourmand'],
        '💋': ['bisou', 'rouge', 'levres', 'baiser'],
        '🌶️': ['piment', 'chili', 'pique', 'epice'],
        '🥵': ['chaud', 'rouge', 'transpire', 'visage'],
        '😏': ['malicieux', 'sourire', 'sous entendu'],
        '😈': ['diable', 'malice', 'coquin'],
        '🐰': ['lapin', 'animal'],
        '🦶': ['pied', 'fetiche'],
        '🌷': ['tulipe', 'fleur'],

        // ═══════════════════════════════════════════════════════════════
        // Emojis bonus : trouvables par recherche mais absents des onglets
        // ═══════════════════════════════════════════════════════════════
        //
        // Emojis qui ne sont rattachés à aucun pack thématique (ce sont
        // typiquement ceux des onglets natifs Visages, Cœurs, Populaires,
        // Gestes), mais qu'on indexe ici pour étendre la portée de la
        // barre de recherche. Aucun impact sur l'affichage des onglets.

        // Visages - émotions courantes
        '😀': ['sourire', 'content', 'happy', 'visage'],
        '😁': ['sourire', 'dents', 'rire'],
        '😂': ['rire', 'larmes', 'mdr', 'lol'],
        '🤣': ['rire', 'sol', 'mdr', 'ptdr'],
        '😃': ['sourire', 'content', 'joyeux'],
        '😄': ['sourire', 'joyeux', 'content'],
        '😅': ['sourire', 'sueur', 'gene'],
        '😆': ['rire', 'yeux fermes'],
        '😉': ['clin oeil', 'wink', 'complice'],
        '😊': ['sourire', 'mignon', 'rougit'],
        '🙂': ['sourire', 'leger'],
        '🙃': ['envers', 'tete bas', 'ironie'],
        '😇': ['ange', 'aureole', 'sage'],
        '🥰': ['amour', 'coeurs', 'amoureux'],
        '😍': ['amoureux', 'coeurs yeux', 'love'],
        '🤩': ['etoiles', 'wow', 'fan', 'admiratif'],
        '😘': ['bisou', 'kiss', 'coeur'],
        '😗': ['bisou', 'siffle'],
        '😚': ['bisou', 'yeux fermes'],
        '🤗': ['calin', 'hug', 'embrasse'],
        '🤔': ['reflexion', 'pense', 'doute'],
        '🤨': ['sceptique', 'sourcil', 'doute'],
        '😐': ['neutre', 'impassible'],
        '😑': ['sans expression', 'blase'],
        '😶': ['sans bouche', 'silence', 'muet'],
        '🙄': ['yeux ciel', 'eye roll', 'agace'],
        '😏': ['malicieux', 'sourire', 'sous entendu'],
        '😣': ['perseverant', 'effort'],
        '😥': ['decu', 'soulage', 'triste'],
        '😮': ['surprise', 'bouche ouverte', 'choc'],
        '😯': ['surpris', 'silencieux'],
        '😪': ['somnole', 'fatigue', 'morve'],
        '😫': ['fatigue', 'epuise', 'rale'],
        '😴': ['dort', 'zzz', 'sommeil'],
        '😌': ['soulage', 'apaise', 'serein'],
        '🙁': ['triste', 'leger', 'decu'],
        '☹️': ['triste', 'mecontent'],
        '😢': ['pleure', 'larme', 'triste'],
        '😭': ['pleure', 'sanglots', 'tristesse'],
        '😤': ['enerve', 'fume', 'colere'],
        '😠': ['fache', 'colere'],
        '😡': ['rouge', 'furieux', 'colere'],
        '🤬': ['jure', 'insulte', 'colere'],
        '😱': ['horreur', 'cri', 'peur', 'scream'],
        '😨': ['peur', 'effraye'],
        '😰': ['anxieux', 'sueur'],
        '😓': ['decu', 'sueur', 'epuise'],
        '🤯': ['mind blown', 'tete explose', 'choc'],
        '🥳': ['fete', 'chapeau', 'anniversaire'],
        '🥺': ['supplie', 'yeux mignons', 'pitie'],
        '😬': ['grimace', 'gene'],
        '🤥': ['ment', 'pinocchio', 'nez long'],
        '🤫': ['chut', 'silence'],
        '🤭': ['oups', 'main bouche'],
        '🤐': ['bouche cousue', 'silence'],
        '🤑': ['argent', 'dollar', 'cupide'],
        '🤠': ['cowboy', 'chapeau'],
        '🤡': ['clown', 'rigolo'],
        '👻': ['fantome', 'halloween'],
        '💀': ['mort', 'crane', 'skull'],
        '☠️': ['pirate', 'crane os'],
        '👽': ['extraterrestre', 'alien'],
        '🤖': ['robot', 'machine'],
        '💩': ['caca', 'crotte', 'poop'],
        '👀': ['yeux', 'regard', 'observe', 'voir', 'mate'],

        // Cœurs et amour
        '❤️': ['coeur', 'rouge', 'amour'],
        '🧡': ['coeur', 'orange'],
        '💛': ['coeur', 'jaune', 'amitie'],
        '💚': ['coeur', 'vert'],
        '💙': ['coeur', 'bleu'],
        '💜': ['coeur', 'violet'],
        '🖤': ['coeur', 'noir'],
        '🤍': ['coeur', 'blanc'],
        '🤎': ['coeur', 'marron'],
        '💔': ['coeur brise', 'rupture', 'chagrin'],
        '❣️': ['coeur', 'exclamation'],
        '💕': ['coeurs', 'amour', 'deux'],
        '💞': ['coeurs', 'tournent', 'amour'],
        '💓': ['coeur', 'bat', 'pulsation'],
        '💗': ['coeur', 'grandit', 'amour'],
        '💖': ['coeur', 'brillant', 'paillettes'],
        '💘': ['coeur', 'fleche', 'cupidon'],
        '💝': ['coeur', 'noeud', 'cadeau'],
        '💟': ['decoration coeur'],

        // Gestes / mains
        '👍': ['pouce haut', 'ok', 'jaime', 'thumbs up'],
        '👎': ['pouce bas', 'dislike', 'thumbs down'],
        '👌': ['ok', 'parfait', 'main'],
        '✌️': ['victoire', 'peace', 'doigts'],
        '🤞': ['doigts croises', 'chance'],
        '🤟': ['je t aime', 'love you sign'],
        '🤙': ['call me', 'cool', 'shaka'],
        '👈': ['gauche', 'doigt'],
        '👉': ['droite', 'doigt'],
        '👆': ['haut', 'doigt'],
        '👇': ['bas', 'doigt'],
        '☝️': ['index', 'haut', 'attention'],
        '✋': ['main levee', 'stop', 'high five'],
        '🤚': ['main dos', 'arret'],
        '🖐️': ['main ouverte', 'cinq doigts'],
        '🖖': ['vulcain', 'spock', 'star trek'],
        '👋': ['salut', 'bye', 'main agitee', 'coucou', 'hello'],
        '🤝': ['poignee de main', 'accord', 'deal'],
        '👏': ['applaudit', 'bravo', 'mains'],
        '🙌': ['mains levees', 'celebration'],
        '🙏': ['merci', 'prie', 'svp', 'priere'],
        '💪': ['biceps', 'fort', 'muscle'],
        '👊': ['poing', 'check', 'fist bump'],
        '✊': ['poing leve', 'force', 'resistance'],
        '🤛': ['poing gauche'],
        '🤜': ['poing droit'],
        '🫶': ['coeur mains', 'amour mains'],

        // Populaires divers
        '⭐': ['etoile'],
        '🌈': ['arc en ciel', 'pluie soleil'],
        '☀️': ['soleil', 'beau temps'],
        '🌙': ['lune', 'nuit', 'croissant'],
        '⏰': ['reveil', 'horloge', 'alarme'],
        '⌛': ['sablier', 'temps', 'attente'],
        '✅': ['valide', 'ok', 'coche', 'check'],
        '❌': ['croix', 'erreur', 'non'],
        '❓': ['question', 'interrogation'],
        '❗': ['exclamation', 'attention'],
        '⚠️': ['attention', 'warning', 'danger']
    };

    // Logs verbeux en console.
    const DEBUG = false;

    // ─── 10-utils.js ───
    // ═══════════════════════════════════════════════════════════════════════
    // SECTION : UTILITAIRES
    // ═══════════════════════════════════════════════════════════════════════

    function debug(...args) {
        if (DEBUG) console.log('[c411-audio-emojis]', ...args);
    }

    // FRAGILE : c411 expose le token CSRF (nuxt-csurf) via meta tag SSR. Si
    // Nuxt change de stratégie, ce sélecteur cassera et l'API réactions
    // répondra en 403.
    function getCsrfToken() {
        const meta = document.querySelector('meta[name="csrf-token"]');
        return meta?.content || null;
    }

    // Lit l'identifiant numérique du canal courant depuis l'URL (?channel=N).
    // Retourne null hors de la page /messages.
    function getCurrentChannelId() {
        const params = new URLSearchParams(window.location.search);
        const channel = params.get('channel');
        if (!channel || !/^\d+$/.test(channel)) return null;
        return channel;
    }

    function getEmojiPackForCurrentChannel() {
        const channelId = getCurrentChannelId();
        if (!channelId) return null;
        return CHANNEL_EMOJI_PACKS[channelId] || null;
    }

    // Normalisation pour la recherche : minuscule + suppression des
    // diacritiques (NFD puis dépouillement des combining marks U+0300–U+036F).
    // "Café" et "cafe" matchent indifféremment.
    function normalize(str) {
        return String(str)
            .toLowerCase()
            .normalize('NFD')
            .replace(/[̀-ͯ]/g, '');
    }

    // Recherche transverse. Itère sur EMOJI_KEYWORDS plutôt que sur les packs :
    // tout émoji ayant des mots-clés est cherchable, qu'il soit ou non rangé
    // dans un pack. C'est ce qui permet d'ajouter des émojis bonus (visages,
    // cœurs, gestes…) cherchables sans les afficher dans un onglet.
    // Match en sous-chaîne sur la forme normalisée (cf. `normalize`).
    function searchEmojis(query) {
        const q = normalize(query).trim();
        if (!q) return [];
        const results = [];
        for (const [emoji, keywords] of Object.entries(EMOJI_KEYWORDS)) {
            if (keywords.some(kw => normalize(kw).includes(q))) {
                results.push(emoji);
            }
        }
        return results;
    }

    // Compatible Vue v-model : un simple `textarea.value = ...` ne déclenche pas
    // le setter réactif. On passe par le setter natif du prototype puis on
    // émet un évènement 'input' que Vue intercepte.
    function setTextareaValueReactively(textarea, newValue) {
        const descriptor = Object.getOwnPropertyDescriptor(
            Object.getPrototypeOf(textarea), 'value'
        );
        if (descriptor?.set) {
            descriptor.set.call(textarea, newValue);
        } else {
            textarea.value = newValue;
        }
        textarea.dispatchEvent(new Event('input', { bubbles: true }));
    }

    function findComposerTextarea() {
        return document.querySelector('textarea[placeholder*="message" i]');
    }

    // Insère `emoji` à la position du curseur dans la zone de saisie.
    function insertEmojiInComposer(emoji) {
        const textarea = findComposerTextarea();
        if (!textarea) {
            debug('insertEmojiInComposer: textarea introuvable');
            return false;
        }
        const start = textarea.selectionStart ?? textarea.value.length;
        const end = textarea.selectionEnd ?? textarea.value.length;
        const before = textarea.value.slice(0, start);
        const after = textarea.value.slice(end);
        const next = before + emoji + after;
        const cursor = start + emoji.length;

        setTextareaValueReactively(textarea, next);
        textarea.setSelectionRange(cursor, cursor);
        textarea.focus();
        return true;
    }

    // Cherche le bouton trigger Reka actuellement ouvert (aria-expanded=true)
    // qui correspond à un picker d'émojis (rédaction ou réaction). Reka peut
    // téléporter le contenu hors du DOM du message, mais le trigger lui reste
    // en place - c'est par lui qu'on retrouve le contexte.
    function findOpenPickerTrigger() {
        const candidates = document.querySelectorAll(
            'button[aria-expanded="true"][aria-haspopup="dialog"]'
        );
        for (const btn of candidates) {
            const title = btn.getAttribute('title');
            if (title === 'Insérer un emoji' || title === 'Ajouter une réaction') {
                return btn;
            }
        }
        return null;
    }

    // Retourne le popover Reka actuellement ouvert pour un picker d'émojis.
    // S'appuie sur `aria-controls` du trigger ouvert, qui pointe vers l'ID
    // du contenu téléporté. Ce popover apparaît dès l'ouverture en mode
    // replié (avant que l'utilisateur déplie via `+`), ce qui permet
    // d'injecter la barre de recherche tout en haut.
    function findOpenPickerPopover() {
        const trigger = findOpenPickerTrigger();
        if (!trigger) return null;
        const id = trigger.getAttribute('aria-controls');
        if (!id) return null;
        return document.getElementById(id);
    }

    // ─── 20-api.js ───
    // ═══════════════════════════════════════════════════════════════════════
    // SECTION : API C411 (réactions)
    // ═══════════════════════════════════════════════════════════════════════

    // POST une réaction `emoji` sur le message `messageId` du canal `channelId`.
    // Endpoint : POST /api/messages/channels/{channelId}/messages/{messageId}/reactions
    // Headers : csrf-token (nuxt-csurf), content-type JSON.
    // Body : { emoji }
    async function postReactionToApi(channelId, messageId, emoji) {
        // Garde-fous d'injection - channelId vient de l'URL et messageId du DOM.
        if (!/^\d+$/.test(String(channelId))) throw new Error('Canal invalide.');
        if (!/^\d+$/.test(String(messageId))) throw new Error('Message invalide.');

        const token = getCsrfToken();
        if (!token) throw new Error('CSRF token introuvable');

        const url = `/api/messages/channels/${channelId}/messages/${messageId}/reactions`;
        const res = await fetch(url, {
            method: 'POST',
            credentials: 'include',
            headers: {
                'content-type': 'application/json',
                'csrf-token': token,
                accept: 'application/json'
            },
            body: JSON.stringify({ emoji })
        });

        const text = await res.text();
        let data = null;
        try {
            data = text ? JSON.parse(text) : null;
        } catch {
            // Réponse non-JSON (page d'erreur HTML, etc.)
        }

        if (!res.ok || data?.error != null || data?.success === false) {
            const message = data?.statusMessage || data?.message || `HTTP ${res.status}`;
            throw new Error(message);
        }

        return data;
    }

    // ─── 30-picker.js ───
    // ═══════════════════════════════════════════════════════════════════════
    // SECTION : INJECTION DE L'ONGLET CUSTOM DANS LE PICKER D'EMOJIS
    // ═══════════════════════════════════════════════════════════════════════
    //
    // Le picker natif est un composant Vue (rendu via Reka UI) qui contient :
    //   - une rangée d'émojis "populaires" (mode replié)
    //   - une barre d'onglets (Visages, Cœurs, Populaires, Gestes)
    //   - une grille 8 colonnes d'émojis (mode déplié)
    //
    // On injecte un onglet "Audio" en première position, on remplace la
    // grille par nos émojis, et on présélectionne l'onglet à l'ouverture.

    const MARKER_AUDIO_TAB = 'data-c411-audio-tab';
    const MARKER_AUDIO_GRID = 'data-c411-audio-grid';
    const MARKER_SEARCH_INPUT = 'data-c411-search-input';
    const MARKER_SEARCH_RESULTS = 'data-c411-search-results';
    const MARKER_POPOVER_AUGMENTED = 'data-c411-popover-augmented';
    // Mémorise le `display` natif d'un enfant masqué pendant une recherche,
    // pour le restaurer à l'effacement de la requête.
    const MARKER_NATIVE_HIDDEN = 'data-c411-native-hidden';

    // Classes Tailwind exactes utilisées par le picker natif (relevées sur le
    // DOM de production). Si C411 change son thème, ces classes seront à
    // réaligner.
    const TAB_BASE_CLASSES = ['px-2', 'py-1', 'text-xs', 'rounded-md', 'whitespace-nowrap', 'transition-colors'];
    const TAB_ACTIVE_CLASSES = ['bg-emerald-500', 'text-white'];
    const TAB_INACTIVE_CLASSES = ['bg-muted', 'text-default', 'hover:bg-accented', 'hover:bg-elevated'];
    const EMOJI_BTN_CLASSES = 'w-8 h-8 flex items-center justify-center rounded-lg hover:bg-muted hover:bg-elevated transition-colors text-lg';
    const GRID_CLASSES = 'grid grid-cols-8 gap-1 p-2 max-h-40 overflow-y-auto';
    // Conteneur de la barre de recherche : padding cohérent avec la grille.
    const SEARCH_WRAPPER_CLASSES = 'px-2 pt-2';
    // Input : on réutilise bg-muted/text-default pour suivre le thème (clair/sombre).
    const SEARCH_INPUT_CLASSES = 'w-full px-2 py-1 text-xs rounded-md bg-muted text-default placeholder-toned focus:outline-none focus:ring-1 focus:ring-emerald-500';
    // Message "aucun résultat" : style toned discret.
    const SEARCH_EMPTY_CLASSES = 'p-2 text-xs text-toned text-center';

    // Heuristique : une barre d'onglets contient des boutons textuels avec au
    // moins l'un des libellés natifs connus, et porte les classes flex+overflow.
    function isPickerTabBar(el) {
        if (!el || !el.classList) return false;
        if (!el.classList.contains('flex')) return false;
        if (!el.classList.contains('overflow-x-auto')) return false;
        const buttons = el.querySelectorAll(':scope > button');
        if (buttons.length < 2) return false;
        const labels = Array.from(buttons).map(b => b.textContent.trim());
        return labels.includes('Visages')
            || labels.includes('Cœurs')
            || labels.includes('Populaires')
            || labels.includes('Gestes');
    }

    // Retrouve la grille NATIVE (gérée par Vue) à partir de la barre d'onglets.
    // Exclut explicitement notre propre grille pour ne jamais la confondre.
    function findNativeGridFromTabBar(tabBar) {
        let next = tabBar.nextElementSibling;
        while (next) {
            if (next.classList?.contains('grid')
                && next.classList.contains('grid-cols-8')
                && !next.hasAttribute(MARKER_AUDIO_GRID)) {
                return next;
            }
            next = next.nextElementSibling;
        }
        return null;
    }

    // Retrouve notre grille custom déjà injectée dans ce picker.
    function findAudioGridInPicker(tabBar) {
        let next = tabBar.nextElementSibling;
        while (next) {
            if (next.hasAttribute?.(MARKER_AUDIO_GRID)) return next;
            next = next.nextElementSibling;
        }
        return null;
    }

    // Détermine le contexte du picker à partir du trigger ouvert.
    // - 'reaction' + messageId : trigger inclus dans un message
    // - 'composer' : trigger dans la barre de saisie (pas de message parent)
    function determinePickerContext() {
        const trigger = findOpenPickerTrigger();
        if (!trigger) return { kind: 'composer', messageId: null, triggerButton: null };
        const messageEl = trigger.closest('[data-message-id]');
        if (messageEl) {
            return {
                kind: 'reaction',
                messageId: messageEl.getAttribute('data-message-id'),
                triggerButton: trigger
            };
        }
        return { kind: 'composer', messageId: null, triggerButton: trigger };
    }

    function applyTabActiveState(button, active) {
        button.className = '';
        TAB_BASE_CLASSES.forEach(c => button.classList.add(c));
        const classes = active ? TAB_ACTIVE_CLASSES : TAB_INACTIVE_CLASSES;
        classes.forEach(c => button.classList.add(c));
    }

    function createAudioTabButton(label, active) {
        const btn = document.createElement('button');
        btn.type = 'button';
        btn.textContent = label;
        btn.setAttribute(MARKER_AUDIO_TAB, 'true');
        applyTabActiveState(btn, active);
        return btn;
    }

    function deactivateNativeTabs(tabBar) {
        const buttons = tabBar.querySelectorAll(`:scope > button:not([${MARKER_AUDIO_TAB}])`);
        buttons.forEach(b => applyTabActiveState(b, false));
    }

    // Ferme le picker en simulant un clic sur son trigger (toggle Reka).
    function closePickerViaTrigger(trigger) {
        if (!trigger) return;
        if (trigger.getAttribute('aria-expanded') !== 'true') return;
        trigger.click();
    }

    async function handleEmojiClick(emoji, context) {
        try {
            if (context.kind === 'composer') {
                insertEmojiInComposer(emoji);
            } else if (context.kind === 'reaction' && context.messageId) {
                const channelId = getCurrentChannelId();
                if (channelId) {
                    await postReactionToApi(channelId, context.messageId, emoji);
                }
            }
        } catch (err) {
            debug('handleEmojiClick failed:', err);
        } finally {
            closePickerViaTrigger(context.triggerButton);
        }
    }

    function buildEmojiButton(emoji, context) {
        const btn = document.createElement('button');
        btn.type = 'button';
        btn.className = EMOJI_BTN_CLASSES;
        btn.textContent = emoji;
        btn.addEventListener('click', (e) => {
            e.preventDefault();
            e.stopPropagation();
            handleEmojiClick(emoji, context);
        });
        return btn;
    }

    function buildAudioGrid(emojis, context) {
        const grid = document.createElement('div');
        grid.className = GRID_CLASSES;
        grid.setAttribute(MARKER_AUDIO_GRID, 'true');
        emojis.forEach(emoji => grid.appendChild(buildEmojiButton(emoji, context)));
        return grid;
    }

    function buildSearchWrapper(onInput, onEnter) {
        const wrapper = document.createElement('div');
        wrapper.className = SEARCH_WRAPPER_CLASSES;
        wrapper.setAttribute(MARKER_SEARCH_INPUT, 'true');

        const input = document.createElement('input');
        input.type = 'search';
        input.placeholder = 'Rechercher un émoji…';
        input.className = SEARCH_INPUT_CLASSES;
        // Le picker écoute aussi 'input' sur ses descendants : on stoppe la
        // propagation pour éviter qu'un keystroke ne soit interprété ailleurs.
        input.addEventListener('input', (e) => {
            e.stopPropagation();
            onInput(input.value);
        });
        input.addEventListener('keydown', (e) => {
            if (e.key === 'Escape') {
                e.preventDefault();
                e.stopPropagation();
                input.value = '';
                onInput('');
            } else if (e.key === 'Enter') {
                e.preventDefault();
                e.stopPropagation();
                onEnter();
            }
        });
        wrapper.appendChild(input);
        return wrapper;
    }

    // Grille de résultats vide ; sera peuplée par `updateSearchResults`.
    function buildSearchResultsGrid() {
        const grid = document.createElement('div');
        grid.className = GRID_CLASSES;
        grid.setAttribute(MARKER_SEARCH_RESULTS, 'true');
        grid.style.display = 'none';
        return grid;
    }

    function updateSearchResults(resultsGrid, query, context) {
        resultsGrid.replaceChildren();
        const matches = searchEmojis(query);
        if (matches.length === 0) {
            const empty = document.createElement('div');
            empty.className = SEARCH_EMPTY_CLASSES;
            empty.textContent = 'Aucun émoji trouvé.';
            resultsGrid.appendChild(empty);
            return;
        }
        matches.forEach(emoji => resultsGrid.appendChild(buildEmojiButton(emoji, context)));
    }

    // Bascule le picker sur l'onglet Audio. Pour ne pas casser le rendu Vue
    // (qui gère la grille native), on NE remplace PAS la grille — on la cache
    // via `display:none` et on insère la nôtre comme sibling. La grille Vue
    // reste dans le DOM Vue et reprend son rôle dès qu'on désactive Audio.
    function activateAudioTab(tabBar, pack, context) {
        deactivateNativeTabs(tabBar);
        const audioTab = tabBar.querySelector(`[${MARKER_AUDIO_TAB}]`);
        if (audioTab) applyTabActiveState(audioTab, true);

        const nativeGrid = findNativeGridFromTabBar(tabBar);
        if (nativeGrid) nativeGrid.style.display = 'none';

        let ourGrid = findAudioGridInPicker(tabBar);
        if (!ourGrid) {
            ourGrid = buildAudioGrid(pack.emojis, context);
            const anchor = nativeGrid || tabBar;
            anchor.parentElement.insertBefore(ourGrid, anchor.nextSibling);
        } else {
            ourGrid.style.display = '';
        }
    }

    function deactivateAudioTab(tabBar) {
        const audioTab = tabBar.querySelector(`[${MARKER_AUDIO_TAB}]`);
        if (audioTab) applyTabActiveState(audioTab, false);

        const ourGrid = findAudioGridInPicker(tabBar);
        if (ourGrid) ourGrid.style.display = 'none';

        // Restaure la grille native. Si l'utilisateur clique un onglet natif
        // déjà actif côté Vue, Vue ne re-rendra pas mais le `display` restauré
        // suffit à ré-afficher le contenu.
        const nativeGrid = findNativeGridFromTabBar(tabBar);
        if (nativeGrid) nativeGrid.style.display = '';
    }

    // Cache tous les enfants natifs du popover pendant une recherche active.
    // Mémorise leur `display` initial via un attribut pour le restaurer.
    // On ne touche pas à nos propres injections (input, résultats).
    function hideNativePickerContent(popover) {
        for (const child of popover.children) {
            if (child.hasAttribute(MARKER_SEARCH_INPUT)) continue;
            if (child.hasAttribute(MARKER_SEARCH_RESULTS)) continue;
            if (child.hasAttribute(MARKER_NATIVE_HIDDEN)) continue;
            child.setAttribute(MARKER_NATIVE_HIDDEN, child.style.display || '');
            child.style.display = 'none';
        }
    }

    function restoreNativePickerContent(popover) {
        for (const child of popover.children) {
            if (!child.hasAttribute(MARKER_NATIVE_HIDDEN)) continue;
            const saved = child.getAttribute(MARKER_NATIVE_HIDDEN);
            child.style.display = saved || '';
            child.removeAttribute(MARKER_NATIVE_HIDDEN);
        }
    }

    // Augmente le popover Reka entier : barre de recherche tout en haut +
    // grille de résultats juste en-dessous. Indépendant de l'apparition de
    // la tabBar - fonctionne donc dès le mode replié. L'ajout de l'onglet
    // pack reste dans `augmentPicker(tabBar)`, déclenché plus tard quand
    // l'utilisateur déplie le picker.
    function augmentPopover(popover) {
        if (popover.hasAttribute(MARKER_POPOVER_AUGMENTED)) return;

        // Pas de restriction de canal ici : la recherche est utile partout
        // (les ~250 émojis indexés couvrent visages/cœurs/gestes en plus des
        // packs). L'onglet pack, lui, reste conditionné à `augmentPicker`.
        popover.setAttribute(MARKER_POPOVER_AUGMENTED, 'true');

        const context = determinePickerContext();

        const resultsGrid = buildSearchResultsGrid();
        const wrapper = buildSearchWrapper(
            (query) => {
                if (query.trim()) {
                    hideNativePickerContent(popover);
                    resultsGrid.style.display = '';
                    updateSearchResults(resultsGrid, query, context);
                } else {
                    restoreNativePickerContent(popover);
                    resultsGrid.style.display = 'none';
                }
            },
            () => {
                // Entrée : clic sur le premier émoji des résultats. Réutilise
                // le handler du bouton (insertion ou réaction + fermeture
                // du picker). Si la grille est vide ou contient le message
                // "Aucun émoji", `querySelector('button')` renvoie null.
                const firstBtn = resultsGrid.querySelector('button');
                if (firstBtn) firstBtn.click();
            }
        );

        // Insertion au tout début du popover.
        popover.insertBefore(resultsGrid, popover.firstChild);
        popover.insertBefore(wrapper, popover.firstChild);

        // Auto-focus : on diffère d'un frame pour laisser Reka finir son
        // propre focus management (qui sinon nous voler le focus juste après).
        const input = wrapper.querySelector('input');
        if (input) {
            requestAnimationFrame(() => {
                requestAnimationFrame(() => input.focus());
            });
        }

        debug('popover augmenté');
    }

    // Point d'entrée tabBar : injecte l'onglet du pack en première position
    // si on est dans un canal éligible, et présélectionne.
    // La barre de recherche n'est PAS gérée ici - elle est au niveau popover
    // (cf. `augmentPopover`), pour rester en haut du picker dès le mode replié.
    function augmentPicker(tabBar) {
        const pack = getEmojiPackForCurrentChannel();
        if (!pack) return;

        // Idempotence : onglet déjà injecté -> rien à faire.
        if (tabBar.querySelector(`[${MARKER_AUDIO_TAB}]`)) return;

        // Pas de grille native détectable -> picker incomplet, on n'injecte pas.
        if (!findNativeGridFromTabBar(tabBar)) return;

        const context = determinePickerContext();

        const audioTab = createAudioTabButton(pack.name, true);
        audioTab.addEventListener('click', (e) => {
            e.preventDefault();
            e.stopPropagation();
            activateAudioTab(tabBar, pack, context);
        });

        tabBar.insertBefore(audioTab, tabBar.firstChild);

        // Présélection : on active immédiatement à l'ouverture.
        activateAudioTab(tabBar, pack, context);

        // Quand l'utilisateur clique un onglet natif, Vue rerend la grille
        // naturellement. On désactive juste notre onglet visuellement.
        const nativeTabs = tabBar.querySelectorAll(`:scope > button:not([${MARKER_AUDIO_TAB}])`);
        nativeTabs.forEach(t => {
            t.addEventListener('click', () => deactivateAudioTab(tabBar));
        });

        debug('tabBar augmentée', { context, pack: pack.name });
    }

    // ─── 90-init.js ───
    // ═══════════════════════════════════════════════════════════════════════
    // SECTION : INITIALISATION
    // ═══════════════════════════════════════════════════════════════════════

    function scanForPickers(root) {
        // Popover entier (mode replié ou déplié) : indépendant de `root`,
        // on remonte au trigger ouvert pour retrouver le popover Reka.
        const popover = findOpenPickerPopover();
        if (popover) augmentPopover(popover);

        // TabBar (mode déplié uniquement) : ajout de l'onglet pack si canal
        // couvert. On la cherche dans `root` car elle est insérée par Vue
        // au moment du dépliage.
        const containers = root.querySelectorAll
            ? root.querySelectorAll('div.flex.gap-1.p-2.overflow-x-auto.scrollbar-thin')
            : [];
        containers.forEach(c => {
            if (isPickerTabBar(c)) augmentPicker(c);
        });
    }

    function initObserver() {
        const observer = new MutationObserver((mutations) => {
            for (const m of mutations) {
                for (const node of m.addedNodes) {
                    if (node.nodeType !== Node.ELEMENT_NODE) continue;
                    if (node.matches?.('div.flex.gap-1.p-2.overflow-x-auto.scrollbar-thin')
                        && isPickerTabBar(node)) {
                        augmentPicker(node);
                    } else {
                        scanForPickers(node);
                    }
                }
            }
        });
        observer.observe(document.body, { childList: true, subtree: true });
        // Scan initial au cas où un picker serait déjà ouvert au chargement.
        scanForPickers(document.body);
        debug('observer initialisé');
    }

    if (document.readyState === 'loading') {
        document.addEventListener('DOMContentLoaded', initObserver);
    } else {
        initObserver();
    }

})();