Greasy Fork is available in English.

C411 - Emojis Plus

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

You will need to install an extension such as Tampermonkey, Greasemonkey or Violentmonkey to install this script.

You will need to install an extension such as Tampermonkey to install this script.

You will need to install an extension such as Tampermonkey or Violentmonkey to install this script.

You will need to install an extension such as Tampermonkey or Userscripts to install this script.

You will need to install an extension such as Tampermonkey to install this script.

You will need to install a user script manager extension to install this script.

(I already have a user script manager, let me install it!)

You will need to install an extension such as Stylus to install this style.

You will need to install an extension such as Stylus to install this style.

You will need to install an extension such as Stylus to install this style.

You will need to install a user style manager extension to install this style.

You will need to install a user style manager extension to install this style.

You will need to install a user style manager extension to install this style.

(I already have a user style manager, let me install it!)

// ==UserScript==
// @name         C411 - Emojis Plus
// @namespace    https://c411.org/
// @version      2026.05.13-2
// @description  Ajoute des packs d'emojis dedies par canal au selecteur d'emojis des messages C411, avec preselection automatique. 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 }
    };

    // 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;
    }

    // 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;
    }

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

    // POSTe 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'ÉMOJIS
    // ═══════════════════════════════════════════════════════════════════════
    //
    // 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';

    // 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';

    // 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 buildAudioGrid(emojis, context) {
        const grid = document.createElement('div');
        grid.className = GRID_CLASSES;
        grid.setAttribute(MARKER_AUDIO_GRID, 'true');

        emojis.forEach(emoji => {
            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);
            });
            grid.appendChild(btn);
        });
        return grid;
    }

    // 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 = '';
    }

    // Point d'entrée : reçoit une barre d'onglets fraîchement détectée, injecte
    // l'onglet Audio si on est dans un canal éligible, et présélectionne.
    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('picker augmenté', { context, pack: pack.name });
    }

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

    function scanForPickers(root) {
        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();
    }

})();