Replaces EACH message character in mattermost with an emoji (1 char = 1 emoji)
// ==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()');
})();