/** * @name Muted Regex * @description Hides posts whose text, author name or @handle matches your regular expressions, locally (X's own mute list is untouched). Alt+Shift+M edits the list: one pattern per line, "/pattern/flags" or plain (case-insensitive), "#" for comments. Hidden posts leave a one-line stub you can click to show. * @version 1.2.0 * @author wayBetter * @permissions storage, ui, network:none */ // i18n:begin // This plugin's English words. Translations live in the app's catalogs (plugin..*, // docs/TRANSLATING.md); a plugin of your own can also pass { en: {...}, tr: {...} } to register. const MESSAGES = { "en": { "stub": "Post hidden by Muted Regex ({pattern}). Click to show.", "patternsLabel": "Patterns, one per line", "title": "Muted Regex", "message": "One pattern per line. Plain lines are case-insensitive regular expressions; use /pattern/flags for exact control; # starts a comment. Matched against the post text, author name and @handle.", "cancel": "Cancel", "save": "Save", "saveFailed": "Could not save (storage unavailable).", "active": "Muted Regex: {count, plural, one {# pattern} other {# patterns}} active", "invalid": "Invalid: {patterns}" } }; // i18n:end /** The words in the app's language (api.i18n); English before start() and outside the app. */ let i18n = null; const t = (key, params = {}) => (i18n ? i18n.t(key, params) : String(MESSAGES.en[key] ?? key) .replace(/\{(\w+), plural, one \{([^{}]*)\} other \{([^{}]*)\}\}/g, (m, name, one, other) => (Number(params[name]) === 1 ? one : other).replace('#', String(params[name]))) .replace(/\{(\w+)\}/g, (m, name) => (name in params ? String(params[name]) : m))); module.exports = class MutedRegex { /** Parses the editor text into RegExps; invalid lines are returned separately instead of throwing. */ static parsePatterns(source) { const patterns = []; const invalid = []; for (const raw of String(source || '').split('\n')) { const line = raw.trim(); if (!line || line.startsWith('#')) continue; const literal = /^\/(.+)\/([a-z]*)$/.exec(line); try { // "g"/"y" make .test() stateful; they are dropped. const pattern = literal ? new RegExp(literal[1], literal[2].replace(/[gy]/g, '')) : new RegExp(line, 'i'); patterns.push({ source: line, pattern }); } catch { invalid.push(line); } } return { patterns, invalid }; } start() { const api = window.wayBetter.api; i18n = api.i18n || null; if (i18n) i18n.register(MESSAGES); this.api = api; this.store = api.storage('muted-regex'); this.scope = api.scope(); this.load(); this.scope.css('muted-regex', ` article[data-bt-muted] { display: none !important; } .bt-muted-stub { padding: 10px 16px; font: 400 13px/1.3 var(--wb-font, system-ui, sans-serif); color: var(--wb-muted, inherit); opacity: .75; cursor: pointer; border-bottom: 1px solid var(--wb-border, rgba(127,127,127,.2)); } .bt-muted-stub:hover { opacity: 1; } `); this.scope.observe('article[data-testid="tweet"]', article => this.check(article)); this.scope.on(document, 'keydown', event => { if (event.altKey && event.shiftKey && !event.ctrlKey && !event.metaKey && event.code === 'KeyM') { event.preventDefault(); event.stopPropagation(); this.openEditor(); } }, true); } stop() { this.scope.dispose(); this.unhideAll(); } load() { this.source = this.store.get('patterns', ''); this.patterns = MutedRegex.parsePatterns(this.source).patterns; } unhideAll() { for (const stub of document.querySelectorAll('.bt-muted-stub')) stub.remove(); for (const article of document.querySelectorAll('article[data-bt-muted], article[data-bt-muted-shown]')) { article.removeAttribute('data-bt-muted'); article.removeAttribute('data-bt-muted-shown'); } } match(article) { const tweet = this.api.tweet(article); if (!tweet) return null; const quoted = Array.from(article.querySelectorAll('[data-testid="tweetText"]'), node => node.textContent || '').join('\n'); const haystack = `@${tweet.handle}\n${tweet.name}\n${tweet.text}\n${quoted}`; return this.patterns.find(({ pattern }) => pattern.test(haystack)) || null; } check(article) { if (!this.patterns.length || article.hasAttribute('data-bt-muted-shown')) return; const hit = this.match(article); if (!hit || !article.parentNode) return; article.setAttribute('data-bt-muted', ''); const stub = document.createElement('div'); stub.className = 'bt-muted-stub'; stub.textContent = t('stub', { pattern: hit.source }); stub.addEventListener('click', event => { event.stopPropagation(); article.removeAttribute('data-bt-muted'); article.setAttribute('data-bt-muted-shown', ''); stub.remove(); }); article.before(stub); } /** The pattern editor, a themed dialog from the UI kit (closed by `scope.dispose()` on stop). */ openEditor() { if (this.editor?.open) return; const ui = this.scope.ui; const area = ui.h('textarea', { class: 'wb-mono', rows: 10, spellcheck: false, 'aria-label': t('patternsLabel'), placeholder: 'crypto giveaway\n/^@spam_bot$/m' }); area.value = this.source; this.editor = ui.dialog({ title: t('title'), message: t('message'), width: 560, content: area, actions: [ { label: t('cancel') }, { label: t('save'), tone: 'primary', onClick: handle => { if (!this.store.set('patterns', area.value)) { handle.setError(t('saveFailed')); return false; } this.load(); this.unhideAll(); for (const article of document.querySelectorAll('article[data-testid="tweet"]')) this.check(article); const count = this.patterns.length; this.api.toast(t('active', { count })); }, }, ], }); area.addEventListener('input', () => { const { invalid } = MutedRegex.parsePatterns(area.value); this.editor.setError(invalid.length ? t('invalid', { patterns: invalid.join(', ') }) : null); }); } };