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).
// ==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();
}
})();