For those interested I'll use this page to share the code, with additional comments to explain things. // Comments in the code look like this
The code here is updated to version 1.2, divided by file. Script.js and alt-or-not.css are used on both Twitter and TweetDeck. Twitter.js is used on Twitter and the TweetDeck preview, and tweetDeck.js is only used on TweetDeck. Go to the main Alt Or Not page for more information on the plugin and links to download it.
There are some additional files for the options page, these aren't included here as they only deal with retrieving and saving the options.
Script.js: Global variables and functions
// Observer config: we are checking for changes in the target node (element) and all its children
const cfg = { childList: true, subtree: true };
// Setting our initialization attempts to 0
var tries = 0;
// Default values for our settings
var disableButton = false;
var noCheck = false;
var hideLabels = false;
var hideAlt = false;
var toggleAlt = false;
var lightMode = false;
var prettyScroll = false;
// Loading our values from storage, depending on browser
// This is a function in case we want to reload on the fly later on
const load = function () {
if (typeof browser !== "undefined") {
// Firefox uses the browser namespace
browser.storage.sync.get({
disableButton: false,
noCheck: false,
hideLabels: false,
hideAlt: false,
toggleAlt: false,
lightMode: false,
prettyScroll: false
}, function(items) {
disableButton = items.disableButton;
noCheck = items.noCheck;
hideLabels = items.hideLabels;
hideAlt = items.hideAlt;
toggleAlt = items.toggleAlt;
lightMode = items.lightMode;
prettyScroll = items.prettyScroll;
});
} else {
if (typeof chrome !== "undefined") {
// Chrome and Edge use the chrome namespace
chrome.storage.sync.get({
disableButton: false,
noCheck: false,
hideLabels: false,
hideAlt: false,
toggleAlt: false,
lightMode: false,
prettyScroll: false
}, function(items) {
disableButton = items.disableButton;
noCheck = items.noCheck;
hideLabels = items.hideLabels;
hideAlt = items.hideAlt;
toggleAlt = items.toggleAlt;
lightMode = items.lightMode;
prettyScroll = items.prettyScroll;
});
}
}
}
load();
// We add and remove classes a lot, so compact functions are useful
const cla = function(e, c) {
e.classList.add('tw-alt-'+c);
};
const clr = function(e, c) {
e.classList.remove('tw-alt-'+c);
};
// Function to create our caption element and add it to our container
const caption = function(t, l) {
if (!hideAlt && t != null) {
let a = document.createElement('div');
cla(a, 'txt');
a.append(l);
t.append(a);
}
};
// Function to create our caption container
const captionContainer = function(t) {
// See if the element already exists for the tweet
let c = t.parentElement.querySelector('div.tw-alt-container');
if (c == null) {
// If not, we make a new one
c = document.createElement('div');
// Hiding it for screen readers, no need for repeated content
c.setAttribute('aria-hidden', true);
cla(c, 'container');
// Applying the light mode if it's enabled
if (lightMode) {
cla(c, 'light');
}
// If the feature is selected, we create a button, add an action, and add it to the container
if (toggleAlt) {
// We create a button
let b = document.createElement('button');
cla(b, 'toggle');
b.innerHTML = 'Show alt';
b.addEventListener('click', toggleCaptions);
c.append(b);
// Applying the class to the container that hides the text and shows the button
cla(c, 'hidden');
}
// Add the container to the tweet
t.after(c);
}
// Return the container element
return c;
}
// The function that reveals the alt text
const toggleCaptions = function(e) {
// Finding the parent of the button element
let a = e.target.parentElement;
// Toggling the hidden class on the container
if (a.classList.contains('tw-alt-hidden')) {
clr(a, 'hidden');
} else {
cla(a, 'hidden');
}
}
// The callback function that runs the checking function on each changed node
const callback = function(ml, o) {
ml.forEach(function(m) { check(m.target); });
};
Twitter.js: functions specific to Twitter
// Setting the default texts for terms we check, based on the document language
var text = null;
switch(document.documentElement.lang) {
case 'ar': text = { media: 'الوسائط', image: 'الصورة', video: 'الفيديو المُضمن' }; break; // Arabic
case 'ar-x-fm': text = { media: 'الوسائط', image: 'الصورة', video: 'الفيديو المُضمن' }; break; // Arabic (Feminine)
case 'bn': text = { media: 'মিডিয়া', image: 'চিত্র', video: 'এম্বেড করা ভিডিও' }; break; // Bangla
case 'eu': text = { media: 'Media', image: 'Irudia', video: 'Kapsulatutako bideoa' }; break; // Basque
case 'bg': text = { media: 'Мултимедийно съдържание', image: 'Изображение', video: 'Вграден видеоклип' }; break; // Bulgarian
case 'ca': text = { media: 'Continguts', image: 'Imatge', video: 'Vídeo incrustat' }; break; // Catalan
case 'hr': text = { media: 'Medijski sadržaj', image: 'Slika', video: 'Ugrađeni videozapis' }; break; // Croatian
case 'cs': text = { media: 'Média', image: 'Obrázek', video: 'Vložené video' }; break; // Czech
case 'da': text = { media: 'Medier', image: 'Billede', video: 'Indlejret video' }; break; // Danish
case 'nl': text = { media: 'Media', image: 'Afbeelding', video: 'Ingesloten video' }; break; // Dutch
case 'fil': text = { media: 'Media', image: 'Larawan', video: 'Naka-embed na video' }; break; // Filipino
case 'fi': text = { media: 'Media', image: 'Kuva', video: 'Upotettu video'}; break; // Finnish
case 'fr': text = { media: 'Médias', image: 'Image', video: 'Vidéo intégrée' }; break; // French
case 'gl': text = { media: 'Multimedia', image: 'Imaxe', video: 'Vídeo encaixado' }; break; // Galician
case 'de': text = { media: 'Medien', image: 'Bild', video: 'Eingebettetes Video' }; break; // German
case 'el': text = { media: 'Πολυμέσα', image: 'Εικόνα', video: 'Ενσωματωμένο βίντεο' }; break; // Greek
case 'gu': text = { media: 'મીડિયા', image: 'છબી', video: 'એમ્બેડ કરેલો વિડિઓ' }; break; // Gujarati
case 'he': text = { media: 'מדיה', image: 'תמונה', video: 'סרטון מוטבע' }; break; // Hebrew
case 'hi': text = { media: 'मीडिया', image: 'छवि', video: 'एम्बेडेड वीडियो' }; break; // Hindi
case 'hu': text = { media: 'Média', image: 'Kép', video: 'Beágyazott videó' }; break; // Hungarian
case 'id': text = { media: 'Media', image: 'Gambar', video: 'Video terlekat' }; break; // Indonesian
case 'ga': text = { media: 'Meáin', image: 'Íomhá', video: 'Físeán leabaithe' }; break; // Irish
case 'it': text = { media: 'Contenuti', image: 'Immagine', video: 'Video incorporato' }; break; // Italian
case 'ja': text = { media: 'メディア', image: '画像', video: '埋め込み動画' }; break; // Japanese
case 'kn': text = { media: 'ಮಾಧ್ಯಮ', image: 'ಚಿತ್ರ', video: 'ಎಂಬೆಡ್ ಮಾಡಿದ ವೀಡಿಯೋ' }; break; // Kannada*/
case 'ko': text = { media: '미디어', image: '이미지', video: '담아간 동영상' }; break; // Korean
case 'ms': text = { media: 'Media', image: 'Imej', video: 'Video terbenam' }; break; // Malay
case 'mr': text = { media: 'मिडिया', image: 'प्रतिमा', video: 'एम्बेडेड व्हिडिओ' }; break; // Marathi
case 'nb': text = { media: 'Medier', image: 'Bilde', video: 'Innebygd video' }; break; // Norwegian
case 'fa': text = { media: 'رسانه تصویری', image: 'تصویر', video: 'ویدئوی جاسازیشده' }; break; // Persian
case 'pl': text = { media: 'Multimedia', image: 'Zdjęcie', video: 'Osadzony film' }; break; // Polish
case 'pt': text = { media: 'Mídia', image: 'Imagem', video: 'Vídeo inserido' }; break; // Portuguese
case 'ro': text = { media: 'Conținut media', image: 'Imagine', video: 'Videoclip încorporat' }; break; // Romanian
case 'ru': text = { media: 'Медиа', image: 'Изображение', video: 'Встроенное видео' }; break; // Russian
case 'sr': text = { media: 'Медији', image: 'Слика', video: 'Уграђени видео' }; break; // Serbian
case 'zh': text = { media: '媒体', image: '图像', video: '嵌入式视频' }; break; // Simplified Chinese
case 'sk': text = { media: 'Médiá', image: 'Obrázok', video: 'Vložené video' }; break; // Slovak
case 'es': text = { media: 'Fotos y videos', image: 'Imagen', video: 'Video insertado' }; break; // Spanish
case 'sv': text = { media: 'Medier', image: 'Bild', video: 'Inbäddad video' }; break; // Swedish
case 'ta': text = { media: 'ஊடகம்', image: 'படம்', video: 'உட்பொதிக்கப்பட்ட வீடியோ' }; break; // Tamil
case 'th': text = { media: 'สื่อ', image: 'รูปภาพ', video: 'วิดีโอที่ฝังไว้' }; break; // Thai
case 'zh-Hant': text = { media: '媒體', image: '圖片', video: '嵌入的影片' }; break; // Traditional Chinese
case 'tr': text = { media: 'Medya', image: 'Resim', video: 'Yerleştirilmiş video' }; break; // Turkish
case 'uk': text = { media: 'Медіафайли', image: 'Зображення', video: 'Вбудоване відео' }; break; // Ukrainian
case 'ur': text = { media: 'میڈیا', image: 'تصویر', video: 'ایمبیڈ کردہ ویڈیو' }; break; // Urdu
case 'vi': text = { media: 'Phương tiện', image: 'Hình ảnh', video: 'Video được nhúng' }; break; // Vietnamese
default: text = { media: 'Media', image: 'Image', video: 'Embedded video' }; // English
}
// Checking the tweet forms
const checkForm = function(e) {
// Finding the tweet button and proceding when it is found
let b = document.querySelector('div[data-testid="tweetButtonInline"], div[data-testid="tweetButton"]');
if (b != null) {
// We start assuming a reminder isn't needed, and check if the button is enabled
let remind = false;
// We move through each attachment that's added to the tweet
let attachments = document.querySelectorAll('div[data-testid="attachments"] div[role="group"]');
attachments.forEach(function(i) {
// Check if the alt text is set to the default "Media" text
if (i.getAttribute('aria-label') == text.media) {
// Check if the media is not a video
if (i.querySelector('video') == null) {
// It is an image with default alt text, so we need to remind the user to add it
remind = true;
}
}
});
// Checking if the media added is a GIF with predefined alt text to show this text in the preview
if (!remind && attachments.length == 1) {
let l = document.querySelector('div[data-testid="attachments"] span[data-testid="altTextLabel"]');
if (l != null) {
// Check if an alt text is set and differs from the text in the alt text preview
if (l.innerText == l.parentElement.getAttribute('aria-label') && l.innerText != attachments[0].getAttribute('aria-label')) {
// Grabbing the little icon shown before the alt text preview
let icon = l.querySelector('svg');
// Replace the text with the prefilled alt text
l.innerText = l.innerText.replace(l.parentElement.getAttribute('aria-label'), 'Prefilled alt: '+attachments[0].getAttribute('aria-label'));
// Add the icon before the text
if (icon != null) {
l.prepend(icon);
}
}
}
}
// Finding all tweet buttons, also in case we are checking a thread
document.querySelectorAll('div[data-testid="tweetButtonInline"], div[data-testid="tweetButton"]').forEach(function(btn) {
// Getting the right spot for our label
let t = btn.querySelector('span > span');
if (remind) {
// Add the label
cla(t, 'mis');
// If the user chose to disable buttons, we do that here
if (disableButton) {
btn.setAttribute('aria-disabled', true);
btn.removeAttribute('tabindex');
btn.classList.add('r-icoktb');
btn.style.pointerEvents = 'none';
}
} else {
// Clear the label from the button and enable if needed
if (t.classList.contains('tw-alt-mis')) {
clr(t, 'mis');
btn.removeAttribute('aria-disabled');
btn.setAttribute('tabindex', 0);
btn.classList.remove('r-icoktb');
btn.style.pointerEvents = 'auto';
}
}
});
}
};
// Checking tweets on the timeline for alt text
const check = function(e) {
// Check if the user doesn't have labels and alt text disabled
if (!hideLabels || !hideAlt) {
// Finding every image container we haven't checked yet
e.querySelectorAll('section.css-1dbjc4n div[data-testid="tweetPhoto"]:not(.tw-alt-chk)').forEach(function(p) {
// Finding the alt text in the aria-label attribute of the element
let l = p.getAttribute('aria-label');
// Check if the alt text is empty or the default "Image" text
if (l != text.image && l != "" && l != null) {
// We have alt text! Getting the container and adding the caption
let c = captionContainer(target(p));
caption(c,l);
} else {
// Adding the "No alt" label unless the user disabled it
if (!hideLabels) {
cla(p.closest('a'), 'mis');
}
}
// Adding a class to indicate we have checked this one
cla(p, 'chk');
});
// Checking previews (for GIFs when autoplay is off)
e.querySelectorAll('section.css-1dbjc4n div[data-testid="previewInterstitial"]:not(.tw-alt-chk)').forEach(function(g) {
// Finding the alt text in the aria-label attribute of the element
let l = g.getAttribute('aria-label');
// Check if the alt text is empty or the default "Embedded video" text
if (l != text.video && l != "") {
// We have alt text! Getting the container and adding the caption
let c = captionContainer(target(g));
// Double check to see if alt text has already been added to avoid a duplicate
if (c.querySelector('div.tw-alt-txt') == null) {
caption(c,'GIF: '+l);
}
} else {
// Check the text on the play button to see if it is a GIF, and not a video, before we add our label
let b = g.querySelector('div[data-testid="playButton"]');
if (b != null && b.getAttribute('aria-label').includes('GIF')) {
// Adding the "No alt" label unless the user disabled it
if (!hideLabels) {
cla(g, 'mis');
}
}
}
// Adding a class to indicate we have checked this one
cla(g, 'chk');
});
// Checking videos (for GIFs when autoplay is on)
e.querySelectorAll('section.css-1dbjc4n div[data-testid="videoPlayer"]:not(.tw-alt-chk) video').forEach(function(g) {
// Finding the alt text in the aria-label attribute of the element
let con = g.closest('div[data-testid="videoPlayer"]');
let l = g.getAttribute('aria-label');
// Check if the alt text is empty or the default "Embedded video" text
if (l != text.video && l != "") {
// We have alt text! Getting the container and adding the caption
let c = captionContainer(target(g));
if (c.querySelector('div.tw-alt-txt') == null) {
caption(c,'GIF: '+l);
}
} else {
// Checking the preload attribute to see if it is a GIF, and not a video, before we add our label
let b = g.getAttribute('preload');
if (b == 'auto') {
// Adding the "No alt" label unless the user disabled it
if (!hideLabels) {
cla(con, 'mis');
}
}
}
// Adding a class to indicate we have checked this one
cla(con, 'chk');
});
}
// Remove the tweet from the timeline if the account uses an NFT profile pic (if enabled by the user)
if (hideNFT) {
e.querySelectorAll('section.css-1dbjc4n article[data-testid="tweet"] div[style*="hex-hw-shapeclip"]').forEach(function(n) {
n.closest('article[data-testid="tweet"]').remove();
});
}
// Check the inline form unless the user disabled this feature
if (!noCheck) {
checkForm();
}
};
// Function to find the right element to contain the alt text
const target = function(e) {
let t = e.closest('div[data-testid="tweet"] > :last-child, div[role="link"] > :last-child');
if (t == null) {
t = e.closest('div[data-testid="tweet"], div[role="link"]');
if (t != null) {
t = t.querySelector('div.tw-alt-container');
}
}
if (t == null) {
t = e.closest('.r-1phboty, .r-18bvks7').closest('div.css-1dbjc4n');
}
return t;
}
// Initialising the observers
const init = function() {
var o = false;
let tw = document.querySelector('main');
if (tw != null && o != true) {
// First running our check on tweets already visible
check(tw);
// Start observing the tweets
let otw = new MutationObserver(callback);
otw.observe(tw, cfg);
if (!noCheck) {
// Find and start observing the composer forms (if the option is enabled)
let frm = document.querySelector('div#layers');
if (frm != null) {
let otf = new MutationObserver(checkForm);
otf.observe(frm, cfg);
}
let frm2 = document.querySelector('header[role="banner"]');
if (frm2 != null) {
let otf = new MutationObserver(checkForm);
otf.observe(frm2, cfg);
}
}
o = true;
if (prettyScroll) {
// Add a class to the html element to make use of our scrollbar styling (if the option is enabled)
document.documentElement.classList.add('prettyscroll');
}
}
if (o != true) {
// We aren't observing yet, retry in a second
// We only retry if we have less than 5 failed attempts
tries++;
if (tries < 5) { setTimeout(init, 1000); }
}
};
// Start our first try at initialising
init();
TweetDeck.js: functions specific to TweetDeck
// Setting a global variable to store the default "Add description" label in Tweetdeck
// This way we don't need to keep track of languages
var tdnoalt = '';
// Setting up the callback for our mutation observer, when content of a column changes it gets re-checked
const callbackTD = function(ml, o) {
ml.forEach(function(m) { checkTD(m.target); });
};
// Checking the tweet form
const checkTDForm = function(e) {
// Finding the tweet button and proceding when it is found
let b = document.querySelector('div[data-drawer="compose"] button.js-send-button');
if (b != null) {
// We start assuming a reminder isn't needed, and check if the button is enabled
let remind = false;
// We move through each image that's added to the tweet
document.querySelectorAll('div[data-drawer="compose"] div.js-add-image-description').forEach(function(i) {
// Getting the content of the "Add description" label, and storing it for later if we haven't already
let l = i.innerHTML;
if (tdnoalt == '') {
tdnoalt = l;
}
if (l == tdnoalt) {
// The alt is set to the standard "Add description" text, we will remind the user
remind = true;
}
});
if (remind) {
// Add the label
cla(b, 'mis');
// If the user chose to disable buttons, we do that here
if (disableButton) {
b.classList.add('is-disabled');
b.style.pointerEvents = 'none';
}
} else {
// Clear the label from the button and enable if needed
if (b.classList.contains('tw-alt-mis')) {
clr(b, 'mis');
b.classList.remove('is-disabled');
b.style.pointerEvents = 'auto';
}
}
}
};
// Checking tweets on the timeline for alt text
const checkTD = function(e) {
// Finding every image container we haven't checked yet
e.querySelectorAll('a.js-media-image-link:not(.tw-alt-chk)').forEach(function(p) {
if (p.querySelectorAll('div').length == 0) {
// Finding the alt text in the aria-label attribute of the element
let l = p.getAttribute('title');
let i = p.querySelector('img');
if (l == "" && i != null) {
l = i.getAttribute('alt');
}
// Check if the alt text is empty or the default "Image"
if (l != "Image" && l != "" && l != null) {
// We have alt text! Getting the container and adding the caption
let c = captionContainer(p.closest('.js-media').parentElement);
caption(c,l);
} else {
// Adding the "No alt" label unless the user disabled it
if (!hideLabels) {
cla(p, 'mis');
}
}
}
// Adding a class to indicate we have checked this one
cla(p, 'chk');
});
};
// Initialising the observers
const initTD = function() {
var o = false;
if (document.querySelector('section.js-column') != null) {
if (!hideLabels || !hideAlt) {
// Unless the user disabled both the "No alt" label and alt text display, we find and observer all TweetDeck columns
let otd = new MutationObserver(callbackTD);
document.querySelectorAll('section.js-column').forEach(function(t) {
// First running our check on tweets already visible
checkTD(t);
// Start observing the tweets
otd.observe(t, cfg);
});
}
if (!noCheck) {
// Unless the user disabled the feature, we find and start observing the tweet form
let tdc = document.querySelector('div[data-drawer="compose"]');
if (tdc != null) {
let otdf = new MutationObserver(checkTDForm);
otdf.observe(tdc, cfg);
}
}
o = true;
}
// We aren't observing yet, retry in a second
// We only retry if we have less than 5 failed attempts
if (o != true) {
tries++;
if (tries < 5) { setTimeout(initTD, 1000); }
}
};
// Start our first try at initialising
init();
Alt-or-not.css: styles
// Style for the red "No alt" label
.tw-alt-mis::after {
display: inline-block;
background-color: #800;
color: #FFF;
font-family: sans-serif;
font-size: 1rem;
padding: 0.25rem 0.5rem;
border-radius: 0.5rem;
content: "No alt";
font-weight: bold;
position: absolute;
top: 0.5rem;
left: 0.5rem;
box-shadow: 0 0 2px 0 #000;
}
// Positioning adjustments for the "No alt" label in buttons
div[data-testid="tweetButtonInline"] .tw-alt-mis::after,
div[data-testid="tweetButton"] .tw-alt-mis::after,
button.js-send-button.tw-alt-mis::after {
position: relative;
top: 0;
left: 0;
margin-left: 0.5rem;
}
// The element containing alt texts gets minimal styling to correctly match the width of the image
.tw-alt-container {
min-width: 100%;
max-width: fit-content;
margin: 0;
padding: 0.5rem 0 0 0;
}
// In case the alt text is displayed in a quote tweet it gets padding all around and width is adjusted to fit
.r-rs99b7 .tw-alt-container {
min-width: calc(100% - 1rem);
padding: 0.5rem;
}
// Style of the alt text elements
.tw-alt-txt {
display: block;
box-sizing: border-box;
background-color: #202020;
color: #FFF;
font-family: sans-serif;
font-size: 1rem;
line-height: 1.3rem;
padding: 0.75rem;
margin: 0;
white-space: pre-wrap;
border: 1px solid #505050;
}
// Border adjustments for the first alt text of a tweet
.tw-alt-txt:first-of-type {
border-top-left-radius: 0.5rem;
border-top-right-radius: 0.5rem;
border-bottom-width: 0;
}
// Border adjustments for the last alt text of a tweet (which can also be the first)
.tw-alt-txt:last-of-type {
border-bottom-left-radius: 0.5rem;
border-bottom-right-radius: 0.5rem;
border-bottom-width: 1px;
}
// Hiding the alt text
.tw-alt-hidden .tw-alt-txt {
display: none;
}
// The styling for the "Show alt" button
.tw-alt-container button.tw-alt-toggle,
.tw-alt-container button.tw-alt-toggle:active,
.tw-alt-container button.tw-alt-toggle:hover,
.tw-alt-container button.tw-alt-toggle:focus {
display: none;
background-color: #17bf63;
color: #FFF;
font-family: sans-serif;
font-size: 1rem;
font-weight: bold;
line-height: 1.3rem;
padding: 0.5rem 0.75rem;
border-radius: 2.3rem;
border: 0;
margin: 0;
cursor: pointer;
}
// Giving the button a subtle hover effect in line with Twitter styles
.tw-alt-container button.tw-alt-toggle:hover {
background-color: #15ac59;
}
// Making the button visible when the alt text is hidden
.tw-alt-hidden button.tw-alt-toggle {
display: inline-block !important;
}
// Inverting the alt text colors for the light mode
.tw-alt-light .tw-alt-txt {
background-color: #FFF;
color: #202020;
}
// A few styles for our scrollbars
.prettyscroll, .prettyscroll * {
scrollbar-width: thin;
scrollbar-color: hsl(205, 25%, 75%) transparent;
}
.prettyscroll::-webkit-scrollbar,
.prettyscroll *::-webkit-scrollbar {
width: 10px;
}
.prettyscroll::-webkit-scrollbar-thumb,
.prettyscroll *::-webkit-scrollbar-thumb {
min-height: 50px;
border-radius: 5px;
background-color: hsl(205, 25%, 75%);
}
.prettyscroll section[role="region"] > div.r-19u6a5r {
margin: 0 3px 0 6px; box-shadow: 0 0 3px hsl(0deg,0%,0%,50%);
}