MM Rooster Filter

Replaces EACH message character in mattermost with an emoji (1 char = 1 emoji)

Tendrás que instalar una extensión para tu navegador como Tampermonkey, Greasemonkey o Violentmonkey si quieres utilizar este script.

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

Tendrás que instalar una extensión como Tampermonkey o Violentmonkey para instalar este script.

Necesitarás instalar una extensión como Tampermonkey o Userscripts para instalar este script.

Tendrás que instalar una extensión como Tampermonkey antes de poder instalar este script.

Necesitarás instalar una extensión para administrar scripts de usuario si quieres instalar este script.

(Ya tengo un administrador de scripts de usuario, déjame instalarlo)

Tendrás que instalar una extensión como Stylus antes de poder instalar este script.

Tendrás que instalar una extensión como Stylus antes de poder instalar este script.

Tendrás que instalar una extensión como Stylus antes de poder instalar este script.

Para poder instalar esto tendrás que instalar primero una extensión de estilos de usuario.

Para poder instalar esto tendrás que instalar primero una extensión de estilos de usuario.

Para poder instalar esto tendrás que instalar primero una extensión de estilos de usuario.

(Ya tengo un administrador de estilos de usuario, déjame instalarlo)

// ==UserScript==
// @name         MM Rooster Filter
// @license      MIT
// @namespace    http://tampermonkey.net/
// @version      3.2.1
// @description  Replaces EACH message character in mattermost with an emoji (1 char = 1 emoji)
// @match        https://community.mattermost.com/*
// @grant        GM_getValue
// @grant        GM_setValue
// @grant        GM_registerMenuCommand
// ==/UserScript==

(function() {
    'use strict';

    const DEFAULT_REMOTE_CONFIG_URL = "https://gist.githubusercontent.com/rift0nix/b6a363e5c49d50ad44e72c6a94be8881/raw/91fd145b3838087e30c970915f0bdc70ce96a6dc/gistfile1.txt";
    const CACHE_KEY = 'mm-rooster-config-cache-v1';
    const CONFIG_URL_STORAGE_KEY = 'mm-rooster-remote-config-url';

    const DEFAULT_CONFIG = {
        configVersion: 1,
        remoteConfigUrl: DEFAULT_REMOTE_CONFIG_URL,
        remoteFetchTimeoutMs: 3000,
        remoteCacheTtlMs: 10 * 60 * 1000,
        replacementEmoji: '🐓',
        targets: {
            "username1": "2026-03-12",
            "username2": "1970-01-01"
        },
        selectors: {
            postContainer: ['div.post__content'],
            postRoot: ['.post'],
            avatar: ['img.Avatar'],
            time: ['time.post__time[datetime]'],
            messageBody: ['.post-message__text'],
            sameUserClass: 'same--user',
        },
    };

    let activeConfig = normalizeConfig(DEFAULT_CONFIG);
    let targetUsers = buildTargetUsersMap(activeConfig.targets);
    let replacementEmoji = activeConfig.replacementEmoji;

    // Cache for consecutive messages (avatar / timestamp)
    let lastKnownUser = null;
    const userStateByName = new Map(); // Map<string, { avatar?: Element, timestamp?: Date }>

    console.log('[🐓 Rooster] Configurable mode loaded');

    function toArray(value) {
        if (Array.isArray(value)) return value.filter(Boolean);
        if (typeof value === 'string' && value.trim()) return [value.trim()];
        return [];
    }

    function getSavedRemoteConfigUrl() {
        try {
            if (typeof GM_getValue !== 'function') return null;
            const value = String(GM_getValue(CONFIG_URL_STORAGE_KEY, '') || '').trim();
            return value || null;
        } catch {
            return null;
        }
    }

    function getEffectiveRemoteConfigUrl() {
        return getSavedRemoteConfigUrl() || DEFAULT_REMOTE_CONFIG_URL;
    }

    function saveRemoteConfigUrl(url) {
        if (typeof GM_setValue !== 'function') {
            throw new Error('GM_setValue is unavailable');
        }
        GM_setValue(CONFIG_URL_STORAGE_KEY, url.trim());
    }

    function withStoredRemoteConfigUrl(config) {
        return {
            ...(config || {}),
            remoteConfigUrl: getEffectiveRemoteConfigUrl(),
        };
    }

    function normalizeConfig(raw) {
        const merged = {
            ...DEFAULT_CONFIG,
            ...(raw || {}),
            targets: {
                ...DEFAULT_CONFIG.targets,
                ...((raw && raw.targets) || {}),
            },
            selectors: {
                ...DEFAULT_CONFIG.selectors,
                ...((raw && raw.selectors) || {}),
            },
        };

        merged.replacementEmoji = typeof merged.replacementEmoji === 'string' && merged.replacementEmoji.trim()
            ? merged.replacementEmoji
            : DEFAULT_CONFIG.replacementEmoji;

        merged.remoteConfigUrl = typeof merged.remoteConfigUrl === 'string' && merged.remoteConfigUrl.trim()
            ? merged.remoteConfigUrl
            : DEFAULT_CONFIG.remoteConfigUrl;

        merged.remoteFetchTimeoutMs = Number.isFinite(merged.remoteFetchTimeoutMs)
            ? Math.max(1000, merged.remoteFetchTimeoutMs)
            : DEFAULT_CONFIG.remoteFetchTimeoutMs;

        merged.remoteCacheTtlMs = Number.isFinite(merged.remoteCacheTtlMs)
            ? Math.max(10 * 1000, merged.remoteCacheTtlMs)
            : DEFAULT_CONFIG.remoteCacheTtlMs;

        merged.selectors = {
            postContainer: toArray(merged.selectors.postContainer),
            postRoot: toArray(merged.selectors.postRoot),
            avatar: toArray(merged.selectors.avatar),
            time: toArray(merged.selectors.time),
            messageBody: toArray(merged.selectors.messageBody),
            sameUserClass: typeof merged.selectors.sameUserClass === 'string' && merged.selectors.sameUserClass.trim()
                ? merged.selectors.sameUserClass.trim()
                : DEFAULT_CONFIG.selectors.sameUserClass,
        };

        return merged;
    }

    function buildTargetUsersMap(targetsObj) {
        const result = new Map();
        Object.entries(targetsObj || {}).forEach(([username, dateIso]) => {
            const normalizedUser = String(username || '').toLowerCase().trim();
            const d = new Date(`${dateIso}T00:00:00`);
            if (normalizedUser && !Number.isNaN(d.getTime())) {
                result.set(normalizedUser, d);
            }
        });
        return result;
    }

    function applyConfig(config) {
        activeConfig = normalizeConfig(config);
        targetUsers = buildTargetUsersMap(activeConfig.targets);
        replacementEmoji = activeConfig.replacementEmoji;
        console.log('[🐓] Config applied', {
            users: targetUsers.size,
            emoji: replacementEmoji,
            postContainerSelectors: activeConfig.selectors.postContainer,
        });
    }

    function getCachedConfigEntry() {
        try {
            const raw = localStorage.getItem(CACHE_KEY);
            if (!raw) return null;
            const parsed = JSON.parse(raw);
            if (!parsed || typeof parsed !== 'object') return null;
            if (!parsed.config || typeof parsed.fetchedAt !== 'number') return null;
            return parsed;
        } catch {
            return null;
        }
    }

    function setCachedConfigEntry(config) {
        try {
            localStorage.setItem(CACHE_KEY, JSON.stringify({
                fetchedAt: Date.now(),
                config,
            }));
        } catch {
            // Ignore storage failures
        }
    }

    function shouldRefreshConfig(cachedEntry, ttlMs) {
        if (!cachedEntry) return true;
        return Date.now() - cachedEntry.fetchedAt > ttlMs;
    }

    async function fetchRemoteConfig(url, timeoutMs) {
        const controller = new AbortController();
        const timer = setTimeout(() => controller.abort(), timeoutMs);
        try {
            const response = await fetch(url, {
                method: 'GET',
                cache: 'no-store',
                signal: controller.signal,
            });
            if (!response.ok) {
                throw new Error(`HTTP ${response.status}`);
            }
            const json = await response.json();
            if (!json || typeof json !== 'object') {
                throw new Error('Invalid config payload');
            }
            return json;
        } finally {
            clearTimeout(timer);
        }
    }

    async function loadAndApplyConfig() {
        const cachedEntry = getCachedConfigEntry();
        if (cachedEntry) {
            applyConfig(withStoredRemoteConfigUrl(cachedEntry.config));
            console.log('[🐓] Using cached config');
        } else {
            applyConfig(withStoredRemoteConfigUrl(DEFAULT_CONFIG));
            console.log('[🐓] Using default config');
        }

        const ttl = activeConfig.remoteCacheTtlMs;
        if (!shouldRefreshConfig(cachedEntry, ttl)) {
            return;
        }

        try {
            const remote = await fetchRemoteConfig(
                activeConfig.remoteConfigUrl,
                activeConfig.remoteFetchTimeoutMs
            );
            const merged = normalizeConfig(remote);
            applyConfig(withStoredRemoteConfigUrl(merged));
            setCachedConfigEntry(merged);
            console.log('[🐓] Remote config loaded');
        } catch (error) {
            console.warn('[🐓] Remote config load failed, keep active config:', error);
        }
    }

    function registerMenuCommands() {
        if (typeof GM_registerMenuCommand !== 'function') return;

        GM_registerMenuCommand('Set remote config URL', async function() {
            const current = getEffectiveRemoteConfigUrl();
            const next = window.prompt('Enter remote config URL', current);
            if (next === null) return;

            const value = next.trim();
            if (!value) {
                console.warn('[🐓] Remote config URL is empty, keeping current value');
                return;
            }

            try {
                new URL(value);
                saveRemoteConfigUrl(value);
                localStorage.removeItem(CACHE_KEY);
                await loadAndApplyConfig();
                processPosts();
                console.log('[🐓] Remote config URL updated');
            } catch (error) {
                console.warn('[🐓] Failed to update remote config URL:', error);
            }
        });
    }

    function extractUsername(avatarImg) {
        const alt = avatarImg?.getAttribute('alt');
        if (!alt) return null;
        return alt.split(' ')[0].toLowerCase();
    }

    function getUserState(username) {
        if (!userStateByName.has(username)) {
            userStateByName.set(username, {});
        }
        return userStateByName.get(username);
    }

    function stripHtml(text) {
        return (text || '').replace(/<[^>]*>/g, '').trim();
    }

    function queryFirst(root, selectors) {
        for (const selector of selectors) {
            const element = root.querySelector(selector);
            if (element) return element;
        }
        return null;
    }

    function queryAll(root, selectors) {
        for (const selector of selectors) {
            const list = root.querySelectorAll(selector);
            if (list.length > 0) return Array.from(list);
        }
        return [];
    }

    function closestBySelectors(element, selectors) {
        for (const selector of selectors) {
            const found = element.closest(selector);
            if (found) return found;
        }
        return null;
    }

    function extractTimestamp(container) {
        const timeEl = queryFirst(container, activeConfig.selectors.time);
        if (timeEl) {
            const dt = timeEl.getAttribute('datetime');
            return dt ? new Date(dt) : null;
        }
        return null;
    }

    function toRoosterMask(text) {
        const plainText = stripHtml(text);
        return replacementEmoji.repeat(plainText.length);
    }

    function resolveAvatar(container, post) {
        const directAvatar = queryFirst(container, activeConfig.selectors.avatar);
        if (directAvatar) return directAvatar;

        if (post?.classList.contains(activeConfig.selectors.sameUserClass) && lastKnownUser) {
            const cached = userStateByName.get(lastKnownUser);
            if (cached?.avatar) return cached.avatar;
        }

        return null;
    }

    function resolveMessageDate(container, post, username) {
        const directTimestamp = extractTimestamp(container);
        if (directTimestamp) return directTimestamp;

        if (post?.classList.contains(activeConfig.selectors.sameUserClass)) {
            const cached = userStateByName.get(username);
            if (cached?.timestamp) return cached.timestamp;
        }

        return null;
    }

    function replaceMessageBody(messageBody) {
        if (!messageBody.dataset.originalContent) {
            messageBody.dataset.originalContent = messageBody.innerHTML;
            messageBody.dataset.originalText = messageBody.textContent || '';
        }

        const roosterString = toRoosterMask(messageBody.dataset.originalText);
        messageBody.innerHTML = `<span style="line-height: 1.5;">${roosterString}</span>`;
    }

    function processPost(container) {
        if (container.dataset.roosterDone) return false;

        const post = closestBySelectors(container, activeConfig.selectors.postRoot);
        const avatar = resolveAvatar(container, post);
        if (!avatar) return false;

        const username = extractUsername(avatar);
        if (!username) return false;

        const thresholdDate = targetUsers.get(username);
        if (!thresholdDate) return false;

        const directAvatar = queryFirst(container, activeConfig.selectors.avatar);
        if (directAvatar) {
            const state = getUserState(username);
            state.avatar = directAvatar;
            lastKnownUser = username;
        }

        const messageDate = resolveMessageDate(container, post, username);
        if (!messageDate) return false;

        getUserState(username).timestamp = messageDate;

        if (messageDate < thresholdDate) return false;

        const messageBody = queryFirst(container, activeConfig.selectors.messageBody);
        if (!messageBody) return false;

        replaceMessageBody(messageBody);
        container.dataset.roosterDone = 'true';

        console.log(
            `[🐓] Replaced ${username} @ ${messageDate.toISOString().slice(0, 10)} (≥${thresholdDate
                .toISOString()
                .slice(0, 10)})`
        );
        return true;
    }

    function processPosts() {
        const containers = queryAll(document, activeConfig.selectors.postContainer);
        let replaced = 0;

        containers.forEach(container => {
            if (processPost(container)) replaced++;
        });

        if (replaced > 0) {
            console.log(`[🐓] Total: ${replaced} messages processed`);
        }
    }

    function setupObservers() {
        const observer = new MutationObserver(processPosts);
        observer.observe(document.body, { childList: true, subtree: true });

        let scrollTimeout;
        window.addEventListener('scroll', () => {
            clearTimeout(scrollTimeout);
            scrollTimeout = setTimeout(processPosts, 300);
        }, { passive: true });
    }

    // === CONSOLE COMMANDS ===

    // Undo replacements
    window.roosterUndo = function() {
        document.querySelectorAll('[data-original-content]').forEach(el => {
            el.innerHTML = el.dataset.originalContent;
            delete el.dataset.originalContent;
            delete el.dataset.originalText;
        });
        document.querySelectorAll('[data-rooster-done]').forEach(el => {
            delete el.dataset.roosterDone;
        });

        userStateByName.clear();
        lastKnownUser = null;
        console.log('[🐓] Undo completed');
    };

    // Statistics
    window.roosterStats = function() {
        const stats = {};
        queryAll(document, activeConfig.selectors.avatar).forEach(avatar => {
            const user = extractUsername(avatar);
            if (user) stats[user] = (stats[user] || 0) + 1;
        });
        console.table(stats);
    };

    // Test: how many emoji characters would be generated for text
    window.roosterTest = function(text) {
        const result = toRoosterMask(text);
        console.log(`Input: "${text}"\nOutput: ${result}\nLength: ${result.length}`);
    };

    window.roosterConfigInfo = function() {
        console.log('[🐓] Active config:', activeConfig);
        const cache = getCachedConfigEntry();
        if (cache) {
            console.log('[🐓] Cached at:', new Date(cache.fetchedAt).toISOString());
        } else {
            console.log('[🐓] No cached config');
        }
    };

    window.roosterReloadConfig = async function() {
        localStorage.removeItem(CACHE_KEY);
        await loadAndApplyConfig();
        processPosts();
        console.log('[🐓] Config reloaded');
    };

    async function init() {
        registerMenuCommands();
        await loadAndApplyConfig();
        processPosts();
        setTimeout(processPosts, 1500);
        setupObservers();
    }

    init();

    console.log('[🐓] Commands: roosterUndo(), roosterStats(), roosterTest("hello"), roosterConfigInfo(), roosterReloadConfig()');
})();