/** * @name Alt-text Reminder * @description Reminds you when images in the composer have no description (alt text): a notice while composing and another when you press Post. It never blocks or delays sending. * @version 1.2.0 * @author wayBetter * @permissions ui, network:none */ // X gives attached images a generic alt until a description is added (best-effort, may need updates when X changes). const GENERIC_ALT = /^(|image|media|photo|picture|bild|imagen|immagine|imagem|görsel|resim|画像|이미지|图片|圖片)$/i; // 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": { "headsUp": "Heads up: {count, plural, one {# image} other {# images}} without alt text", "missing": "{count, plural, one {# image has no description (alt text)} other {# images have no description (alt text)}}" } }; // 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 AltTextReminder { start() { const api = window.wayBetter.api; i18n = api.i18n || null; if (i18n) i18n.register(MESSAGES); this.api = api; this.scope = api.scope(); this.scope.css('alt-text-reminder', ` .bt-alt-note { position: fixed; left: 50%; top: 16px; transform: translateX(-50%); z-index: 2147483645; pointer-events: none; width: max-content; max-width: calc(100vw - 32px); box-sizing: border-box; text-align: center; padding: 8px 14px; border-radius: 999px; background: rgb(255, 212, 0); color: #0f1419; font: 600 13px/1.3 system-ui, sans-serif; box-shadow: 0 4px 16px rgba(0,0,0,.25); } `); let frame = 0; const observer = new MutationObserver(() => { if (!frame) frame = requestAnimationFrame(() => { frame = 0; this.update(); }); }); observer.observe(document.documentElement, { childList: true, subtree: true, attributes: true, attributeFilter: ['alt'] }); this.scope.defer(() => { observer.disconnect(); if (frame) cancelAnimationFrame(frame); }); // Capture phase, passive: only reads the composer and shows a toast; the click continues to X untouched. this.scope.on(document, 'click', event => { const button = event.target instanceof Element && event.target.closest('[data-testid="tweetButton"], [data-testid="tweetButtonInline"]'); if (!button) return; const missing = this.missing(button.closest('[role="dialog"]') || document); if (missing) this.api.toast(t('headsUp', { count: missing }), 3500); }, { capture: true, passive: true }); this.scope.defer(() => this.note?.remove()); this.update(); } stop() { this.scope.dispose(); this.note = null; } missing(root) { let count = 0; for (const img of root.querySelectorAll('[data-testid="attachments"] img')) { if (GENERIC_ALT.test((img.getAttribute('alt') || '').trim())) count += 1; } return count; } update() { const missing = this.missing(document); if (!missing) { this.note?.remove(); return; } if (!this.note) { this.note = document.createElement('div'); this.note.className = 'bt-alt-note'; this.note.setAttribute('role', 'status'); } const text = t('missing', { count: missing }); // Only write on change: this node lives inside the observed tree. if (this.note.textContent !== text) this.note.textContent = text; if (!this.note.isConnected) document.body.appendChild(this.note); } };