psychoticmeow / Something Awful Image Fixes

// ==UserScript==
// @name			Something Awful Image Fixes
// @namespace		SA
// @description		Smarter image handling on the Something Awful forums.
// @include			http://forums.somethingawful.com/*
// @version			1.3.4
// @grant			GM_openInTab
// @grant			GM_getValue
// @grant			GM_setValue
// @run-at			document-end
// @icon			http://forums.somethingawful.com/favicon.ico
// ==/UserScript==

"use strict";

var Config = {
	createIssue:		'https://github.com/psychoticmeow/Something-Awful-Image-Fixes/issues/new',
	imgurClientId:		'b0bde100257fa60',
	imgurMaxGifSize:	3 * 1024 * 1024,
	loadingIndicator:	"data:image/png;base64,iVBORw0KGgoAAAANSUhEUgAAADAAAAAwCAMAAABg3Am1AAAAPFBMVEU5Ojg+QD1FRkRPUE5aW1lkZmNucG16e3iBg4CIioeTlZKgop+srqu4urfAwr/GyMXR09Dd4Nzo6uf09/OAsyLcAAAB0klEQVRIx72V63KEIAyFhUAioKL2/d+14SbqgtPt7Gx+dfB8HHLZdBg+E+gX/Rbg13X5g0yAyH+tHOVQiZ5ezn6Sd0D6zcsOQN57ewfctm1jN1MOdQUU6zfspTAx4K7AzPqlm4QKFvoMUDB4KLBlYBYVECvr3UNdYWFiDMIE2GAAjXqSzs8cGVigNA6C3uQEyUBNdp5dqo6YmaAyGob1a7pKr/u+luRhDmGhlPZIEktJYd5DwMkhBIUbyFF96ziFnkkb5dVhkCYRU7OAtCW9O08IuIQ0emqSfFG3cx3fZV+B+PyVGoNBXYfNtodDom59QAPDd0O5KYdTnR/W/pNjD7kfeibawKFn4j/AW0/CL9dLYvNNAO0+C3TONcYVELGFaMt611hZGkPcpwOMi9Fx4NDysgSS3Db3biawfhQ26TGcaDq5KK3i/s4mxxKIcoqeylp71AqICIaKyJNDnnlhGGALGEc+UAxQulZy7tVa6nIpst7yRaMxYQEGQD21Q9pkMBiOIVvIh14T642oQLR4WN5gS8oZiGlT9ycdM069LsCADHRnOpQ0bdgKyCcLzfp82wFwI2uhXmpkUsYXQCBh93+ckOVTBU6HT5Ea90ak0fhI/ALpBRsilkfZxwAAAABJRU5ErkJggg=="
};

var Util = {
	/**
	 * Initialise the page, strip out any assets that we will load.
	 */
	initialise: function(target) {
		var assets = [];

		// Remove content images:
		var images = document.querySelectorAll('td.postbody img');

		for (var index in images) {
			var image = images[index];

			if (typeof image !== 'object') continue;

			var src = image.getAttribute('src');

			// Exclude smilies:
			if (!/somethingawful[.]com[/](images[/]smilies|forumsystem[/]emoticons)[/]/.test(src)) {
				var placeholder = document.createElement('span');

				// Replace the image with a placeholder:
				placeholder.setAttribute('class', 'saif-pending');
				image.parentNode.replaceChild(placeholder, image);

				// Remove a parent link element:
				if (
					placeholder.parentNode instanceof HTMLAnchorElement
					&& placeholder.parentNode.children.length === 1
					&& !!placeholder.parentNode.parentNode
				) {
					placeholder.parentNode.parentNode.replaceChild(
						placeholder,
						placeholder.parentNode
					);
				}

				// Create asset:
				if (/i\.imgur\.com/.test(src)) {
					assets.push(new ImgurAsset(placeholder, src));
				}

				else if (/staticflickr\.com\//.test(src)) {
					assets.push(new FlickrAsset(placeholder, src));
				}

				else {
					assets.push(new GenericAsset(placeholder, src));
				}
			}
		}

		// Reload other images:
		var images = document.querySelectorAll('img');

		for (var index in images) {
			var image = images[index];

			if (!image.parentNode) continue;

			image.parentNode.replaceChild(image.cloneNode(true), image);
		}

		// Reload embedded iframes:
		var iframes = document.querySelectorAll('td.postbody iframe');

		for (var index in iframes) {
			var iframe = iframes[index];

			if (!iframe.parentNode) continue;

			iframe.parentNode.replaceChild(iframe.cloneNode(true), iframe);
		}

		// Fix post table styles:
		var posts = document.querySelectorAll('table.post');

		for (var index in posts) {
			var post = posts[index];

			if (typeof post !== 'object') continue;

			post.style.tableLayout = 'fixed';
		}

		Util.build(target, assets);
	},

	/**
	 * Begin loading assets from the start of the document
	 * until and including the windows viewport.
	 */
	build: function(target, assets) {
		var offset = window.scrollY + window.innerHeight,
			queue = [],
			scroll;

		// Keep the window scrolled to the target:
		if (!!target) {
			offset = Util.getElementOffset(target) + window.innerHeight;

			scroll = setInterval(function() {
				window.scrollTo(0, Util.getElementOffset(target));
			}, 100);
		}

		// Initialise all elements up until the offset:
		for (var index in assets) {
			var asset = assets[index];

			if (asset.getOffset() < offset) {
				queue.push(asset.build());
			}
		}

		// Wait for the queued assets to render:
		Promise.all(queue).then(function() {
			var queue = [];

			// Scroll to the URL target:
			if (!!target) {
				clearInterval(scroll);
				window.scrollTo(0, Util.getElementOffset(target));
			}

			// Load remaining assets:
			for (var index in assets) {
				var asset = assets[index];

				if (!asset.rendered) {
					asset.build();
				}
			}
		});
	},

	/**
	 * Create a formatted error message.
	 */
	createError: function(message, data) {
		var wrapper = document.createElement('div'),
			title = document.createElement('p'),
			footer = document.createElement('p'),
			link = document.createElement('a'),
			raw = document.createElement('pre');

		wrapper.setAttribute('class', 'saif-error');

		title.textContent = 'Sorry, an error occured while making things pretty:';
		wrapper.appendChild(title);

		// Make the data copy+paste safe:
		data = btoa(JSON.stringify(data));
		data = data.match(/.{1,76}/g).join("\n");
		data = message + "\n\n" + data;

		raw.textContent = data;
		raw.style.whiteSpace = 'pre-wrap';
		wrapper.appendChild(raw);

		footer.appendChild(document.createTextNode('If this problem persists, please '));

		data = "```\n" + data + "\n```";
		data = "**What was the error?**\n" + data;
		data = "**Where did this happen?**\n" + window.location + "\n\n" + data;
		data = "**What did you experience?**\n\n\n" + data;

		link.textContent = 'report this issue';
		link.setAttribute('href', Config.createIssue + '?body=' + encodeURIComponent(data));
		link.setAttribute('target', '_blank');
		footer.appendChild(link);

		footer.appendChild(document.createTextNode(' on our issue tracker.'));
		wrapper.appendChild(footer);

		return wrapper;
	},

	/**
	 * Create a simple message.
	 */
	createMessage: function(message) {
		var wrapper = document.createElement('div'),
			title = document.createElement('p');

		wrapper.setAttribute('class', 'saif-error');

		title.textContent = message;
		wrapper.appendChild(title);

		return wrapper;
	},

	/**
	 * Create a simple image element from a given source URL.
	 */
	createImage: function(source) {
		return function(callback) {
			var image = document.createElement('img');

			image.addEventListener('load', function() {
				callback(true, image);
			});

			image.addEventListener('error', function() {
				callback(false, image);
			});

			// Set image source:
			image.setAttribute('src', source);

			return image;
		};
	},

	/**
	 * Inject a stylesheet into the page head.
	 */
	createStyle: function(css) {
		var head = document.querySelectorAll('head')[0],
			style = document.createElement('style');

		style.textContent = css;
		head.appendChild(style);
	},

	/**
	 * Create a video element from a list of source URLs with media types.
	 */
	createVideo: function(sources) {
		return function(callback) {
			var video = document.createElement('video');

			// Set attributes to ensure gif style playback:
			video.setAttribute('preload', 'auto');
			video.setAttribute('autoplay', 'autoplay');
			video.setAttribute('muted', 'muted');
			video.setAttribute('loop', 'loop');
			video.setAttribute('webkit-playsinline', 'webkit-playsinline');

			// Listen for success:
			video.addEventListener('loadeddata', function() {
				callback(true, video);
			});

			// Set the video sources:
			for (var index in sources) {
				var source = document.createElement('source');

				source.setAttribute('src', sources[index]);
				source.setAttribute('type', (
					/[.]webm$/.test(sources[index])
						? 'video/webm'
						: 'video/mp4'
				));

				// Listen for failure:
				if (index == sources.length - 1) {
					source.addEventListener('error', function() {
						callback(false, video, source);
					});
				}

				video.appendChild(source);
			}
		};
	},

	/**
	 * Calculate the offset from the top of the page to the
	 * top of the given element.
	 */
	getElementOffset: function(element) {
		var offset = 0;

		while (element.offsetParent) {
			offset += element.offsetTop;
			element = element.offsetParent;
		}

		return offset;
	}
};

function Deferred() {
	try {
		this.resolve = null;
		this.reject = null;

		this.promise = new Promise(function(resolve, reject) {
			this.resolve = resolve;
			this.reject = reject;
		}.bind(this));

		Object.freeze(this);
	}

	catch (error) {
		throw new Error('Promise/Deferred is not available');
	}
}

var Asset = (function() {
	function Asset(placeholder, source) {
		if (!!placeholder) {
			var self = this;

			self.placeholder = placeholder;
			self.source = source;
			self.wrapper = document.createElement('span');
			self.link = document.createElement('a');

			self.wrapper.setAttribute('class', 'saif-wrapper');
			self.link.setAttribute('target', '_blank');
			self.wrapper.appendChild(self.link);
		}
	};

	Asset.prototype.getOffset = function() {
		var self = this;

		if (!!self.placeholder.parentNode) {
			return Util.getElementOffset(self.placeholder);
		}

		else if (!!self.wrapper.parentNode) {
			return Util.getElementOffset(self.wrapper);
		}

		return 0;
	};

	Asset.prototype.build = function() {
		var self = this;

		if (!self.rendered) {
			self.rendered = new Deferred();

			self.render(self.source);
		}

		return self.rendered.promise;
	};

	Asset.prototype.visible = function() {
		var deferred = new Deferred(),
			chromeSucks = false,
			self = this;

		var scroll = function() {
			if (chromeSucks) return;

			var offset = self.getOffset(),
				max = window.scrollY + (window.innerHeight * 2);

			if (max > offset) {
				chromeSucks = true;
				window.removeEventListener('scroll', scroll);
				deferred.resolve(self);
			}
		};

		window.addEventListener('scroll', scroll);

		return deferred.promise;
	};

	Asset.prototype.setLink = function(href) {
		var self = this;

		self.link.setAttribute('href', href);
	};

	Asset.prototype.setContent = function(content) {
		var self = this;

		self.link.appendChild(content);

		if (!!self.placeholder.parentNode) {
			self.placeholder.parentNode.replaceChild(self.wrapper, self.placeholder);
		}

		self.rendered.resolve(self);
	};

	return Asset;
})();

var FlickrAsset = (function() {
	function FlickrAsset(placeholder, source) {
		this.parent.constructor.call(this, placeholder, source);
	};

	FlickrAsset.prototype = new Asset();
	FlickrAsset.prototype.parent = Asset.prototype;
	FlickrAsset.prototype.constructor = FlickrAsset;

	FlickrAsset.prototype.render = function(source) {
		var bits = /^(.+?\.com\/.+?\/.+?_.+?)(_[sqtmn-zcbhko])?\.(.+?)$/.exec(source),
			self = this;

		// Create an image:
		if (bits) {
			var source = bits[1] + '_b.' + bits[3].toLowerCase();

			Util.createImage(source)(function(success, element) {
				if (success) {
					self.setLink(source);
					self.setContent(element);
				}

				else {
					self.setContent(
						Util.createError('Could not load image.', {
							url:	source
						})
					);
				}
			});
		}

		// The source was invalid:
		else {
			self.setContent(
				Util.createError('Could not parse URL.', {
					url:	source
				})
			);
		}
	};

	return FlickrAsset;
})();

var GenericAsset = (function() {
	function GenericAsset(placeholder, source) {
		this.parent.constructor.call(this, placeholder, source);
	};

	GenericAsset.prototype = new Asset();
	GenericAsset.prototype.parent = Asset.prototype;
	GenericAsset.prototype.constructor = GenericAsset;

	GenericAsset.prototype.render = function(source) {
		var self = this;

		Util.createImage(source)(function(success, element) {
			if (success) {
				self.setLink(source);
				self.setContent(element);
			}

			else {
				self.setContent(
					Util.createError('Could not load image.', {
						url:	source
					})
				);
			}
		});
	};

	return GenericAsset;
})();

var ImgurAsset = (function() {
	function ImgurAsset(placeholder, source) {
		var bits = /\/(.{5}|.{7})[sbtmlh]?\.(jpg|png|gif)/i.exec(source),
			self = this;

		self.parent.constructor.call(self, placeholder, source);
		self.prefetched = new Deferred();

		// Update the source:
		if (bits) {
			self.prefetch(bits[1], 'https://api.imgur.com/3/image/' + bits[1]);
		}

		// Could not parse the image source:
		else {
			self.prefetched.reject(
				Util.createError('Could not parse URL.', {
					url:	self.source
				})
			);
		}
	};

	ImgurAsset.prototype = new Asset();
	ImgurAsset.prototype.parent = Asset.prototype;
	ImgurAsset.prototype.constructor = ImgurAsset;

	ImgurAsset.prototype.build = function() {
		var self = this;

		if (!self.rendered) {
			self.rendered = new Deferred();

			self.prefetched.promise.then(
				function(data) {
					self.render(data);
				},
				function(content) {
					self.setContent(content);
				}
			);
		}

		return self.rendered.promise;
	};

	ImgurAsset.prototype.prefetch = function(id, url) {
		var request = new XMLHttpRequest(),
			data = GM_getValue('imgur-data-' + id),
			self = this;

		// Use stored data:
		if (!!data) {
			self.prefetched.resolve(JSON.parse(data));
		}

		// Load and then store data:
		else {
			request.addEventListener('load', function() {
				try {
					var data = JSON.parse(request.response);

					if (!data || !data.success || !data.data) {
						throw data;
					}
				}

				catch (error) {
					// Image not found:
					if (!!error.status && error.status === 404) {
						self.prefetched.reject(
							Util.createMessage('The image you are requesting does not exist or is no longer available.')
						);
					}

					// Generic error:
					else {
						self.prefetched.reject(
							Util.createError('Invalid response from imgur.com.', {
								url:	url,
								result:	error
							})
						);
					}

					return;
				}

				// Store date for later:
				GM_setValue('imgur-data-' + id, JSON.stringify(data.data));

				self.prefetched.resolve(data.data);
			});

			request.addEventListener('error', function() {
				self.prefetched.reject(
					Util.createError('Could not access imgur.com.', {
						url:	url
					})
				);
			});

			request.open('GET', url, true);
			request.setRequestHeader('Authorization', 'Client-ID ' + Config.imgurClientId);
			request.send();
		}
	};

	ImgurAsset.prototype.render = function(data) {
		var self = this,
			video,
			link;

		// Has a video page:
		if (!!data.gifv) {
			link = data.gifv;
		}

		// Link to the raw image:
		else {
			link = data.link;
		}

		// Show a video
		if (data.animated && Config.imgurMaxGifSize < data.size) {
			if (!!data.webm && !!data.mp4) {
				video = Util.createVideo([data.webm, data.mp4]);
			}

			else if (!!data.webm) {
				video = Util.createVideo([data.webm]);
			}

			else if (!!data.mp4) {
				video = Util.createVideo([data.mp4]);
			}
		}

		var image = function() {
			return Util.createImage(data.link)(function(success, element) {
				if (success) {
					self.setLink(link);
					self.setContent(element);
				}

				else {
					self.setContent(
						Util.createError('Could not load image.', {
							url:	link
						})
					);
				}
			});
		};

		if (!!video) video(function(success, element) {
			if (success) {
				self.setLink(link);
				self.setContent(element);
			}

			else image();
		});

		else image();
	};

	return ImgurAsset;
})();

try {
	Util.createStyle(`
		@keyframes saif-spinner {
			0% {
				transform: rotate(0deg);
			}
			100% {
				transform: rotate(360deg);
			}
		}

		span.saif-pending {
			background: #3b3b3b;
			border-radius: 32px;
			display: inline-block;
			height: 32px;
			overflow: hidden;
			width: 32px;
		}
		span.saif-pending:before {
			animation-duration: 1s;
			animation-name: saif-spinner;
			animation-iteration-count: infinite;
			animation-timing-function: steps(12, end);
			background: url(` + Config.loadingIndicator + `) no-repeat center;
			background-size: 24px 24px;
			content: '';
			display: block;
			height: 32px;
			width: 32px;
		}

		span.saif-wrapper {
			display: inline-block;
			position: relative;
			margin: 5px 0;
			max-width: 100%;
		}
		span.saif-wrapper img,
		span.saif-wrapper video {
			max-height: 1000px;
			max-width: 100%;
			opacity: 1;
			vertical-align: bottom;
		}
		span.saif-wrapper div.saif-error {
			background: #3b3b3b;
			border-radius: 8px;
			color: #ffffff;
			padding: 1px 1em;
		}
		span.saif-wrapper div.saif-error pre {
			color: #cccccc;
			font-size: 0.9em;
			line-height: 1.2;
		}
		span.saif-wrapper div.saif-error a {
			color: #ffffff;
		}
		span.saif-wrapper a.saif-link {
			background: hsla(0, 0%, 10%, 0.7);
			color: #ffffff;
			cursor: pointer;
			display: none;
			font-size: 0.75em;
			left: 0;
			line-height: 1;
			padding: 5px;
			position: absolute;
			right: 0;
			text-decoration: none;
			top: 0;
			z-index: 1;
		}
		span.saif-wrapper:hover a.saif-link {
			display: block;
		}
	`);

	// Prevent images from loading:
	window.stop();

	// Redirect the page:
	if (document.querySelectorAll('meta[http-equiv=refresh]').length) {
		var rule = document.querySelectorAll('meta[http-equiv=refresh]')[0].getAttribute('content');

		if (/URL=(.+)$/.test(rule)) {
			window.location = /URL=(.+)$/.exec(rule)[1];
		}
	}

	// Jump to appropriate place on page:
	else if (!!window.location.hash && document.querySelectorAll(window.location.hash).length) {
		Util.initialise(document.querySelectorAll(window.location.hash)[0]);
	}

	// Load the page normally:
	else {
		Util.initialise();
	}
}

catch (e) {
	console.log("Exception: " + e.name + " Message: " + e.message);
}