wayBetter for X

This commit is contained in:
CRTkafa 2026-10-01 06:01:13 +07:00
commit df5dff619e
720 changed files with 147465 additions and 0 deletions

View file

@ -0,0 +1,85 @@
/**
* @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.<file name>.*,
// 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);
}
};