MM Rooster Filter

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

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         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()');
})();