Greasy Fork is available in English.
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).
// ==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();
}
})();