MM Rooster Filter

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

이 스크립트를 설치하려면 Tampermonkey, Greasemonkey 또는 Violentmonkey와 같은 확장 프로그램이 필요합니다.

이 스크립트를 설치하려면 Tampermonkey와 같은 확장 프로그램을 설치해야 합니다.

이 스크립트를 설치하려면 Tampermonkey 또는 Violentmonkey와 같은 확장 프로그램이 필요합니다.

이 스크립트를 설치하려면 Tampermonkey 또는 Userscripts와 같은 확장 프로그램이 필요합니다.

이 스크립트를 설치하려면 Tampermonkey와 같은 확장 프로그램이 필요합니다.

이 스크립트를 설치하려면 유저 스크립트 관리자 확장 프로그램이 필요합니다.

(이미 유저 스크립트 관리자가 설치되어 있습니다. 설치를 진행합니다!)

이 스타일을 설치하려면 Stylus와 같은 확장 프로그램이 필요합니다.

이 스타일을 설치하려면 Stylus와 같은 확장 프로그램이 필요합니다.

이 스타일을 설치하려면 Stylus와 같은 확장 프로그램이 필요합니다.

이 스타일을 설치하려면 유저 스타일 관리자 확장 프로그램이 필요합니다.

이 스타일을 설치하려면 유저 스타일 관리자 확장 프로그램이 필요합니다.

이 스타일을 설치하려면 유저 스타일 관리자 확장 프로그램이 필요합니다.

(이미 유저 스타일 관리자가 설치되어 있습니다. 설치를 진행합니다!)

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