// ==UserScript==
// @name TDWTF
// @namespace custom
// @description Makes Remy comments visible and performs a variety of funny tricks
// @include http://thedailywtf.com/*
// @include https://thedailywtf.com/*
// @include https://what.thedailywtf.com/*
// @grant GM_xmlhttpRequest
// @grant GM_getValue
// @grant GM_setValue
// @grant GM_deleteValue
// @grant GM.xmlhttpRequest
// @grant GM.getValue
// @grant GM.setValue
// @grant GM.deleteValue
// run-at document-start
// ==/UserScript==
try {
if (typeof GM == 'undefined') {
GM = {
getValue: function getValue() {
return new Promise((resolve, reject) => {
try {
resolve(GM_getValue(...arguments));
} catch (e) {
reject(e);
}
});
},
setValue: function setValue() {
return new Promise((resolve, reject) => {
try {
resolve(GM_setValue(...arguments));
} catch (e) {
reject(e);
}
});
},
};
}
} catch (e) {
// this pattern USED to be necessary in order to get anything in the console when a script error'd...
// Greasemonkey MIGHT log errors by default now, but I haven't bothered to test that, or remove them all...
console.error(e);
}
try {
(async function () {
try {
var loc = await GM.getValue('location');
if (loc == undefined) console.log("No location currently set.");
else console.log("The current location is:", loc);
console.log(`To set or change this script's location, use TDWTF_setLocation('location').`);
unsafeWindow.TDWTF_setLocation = unsafeWindow.eval(`(${function (loc) {
window.setLoc = loc == undefined ? loc : String(loc);
window.dispatchEvent(new Event('loc'));
}})`);
window.addEventListener('loc', e => {
loc = unsafeWindow.setLoc;
delete unsafeWindow.setLoc;
if (loc == undefined) GM.deleteValue('location');
else GM.setValue('location', loc);
console.log('The current location is now:', loc);
});
var styleSheet = document.head.appendChild(document.createElement("style"));
styleSheet.innerHTML = '';
var locationStyles = {
'work': `li[data-tid="22326"] /* NSFW thread */ { display: none; }`
};
if (loc in locationStyles) styleSheet.innerHTML += `/* location-specific styles for '${loc}' */
${locationStyles[loc]}
/* end location-specific styles for '${loc}' */
`;
// detect whether images have loaded correctly, and if any are broken, try changing the file type
// e.g. the image specifies "Image0.png", which doesn't exist, so try "Image0.jpg" and "Image0.gif"
// I dunno if the editors have gotten any better about not doing this, but goddamn if it wasn't
// commonly done in Error'd articles... back when I was actually reading front page articles...
function checkImgs(e) {
function fixImg(img) {
var ext = img.src.split(".").pop().toLowerCase();
["jpg", "gif", "png"].filter(e => e != ext).forEach(e => {
var i = document.createElement("img");
i.onload = function () {
if (this.complete && this.naturalWidth) img.src = this.src;
};
i.src = img.src.split(".").slice(0, -1).concat(e).join(".");
});
}
if (e) {
[].concat.apply([], e.querySelectorAll("img[src^='http:\/\/thedailywtf.com\/']")).forEach(img => {
if (img.complete) {
if (!img.naturalWidth) fixImg(img);
} else {
img.onerror = fixImg.bind(this, img);
}
});
}
}
checkImgs(document.querySelector(".article-body"));
// make HTML comments and invisible elements into visible elements with a green italic style
function parse() {
var d;
if (d = document.getElementById("article-page") || [...document.querySelectorAll(".topic-body .cooked")]) for (var e = [].concat(d); e.length; e.shift()) {
if (e[0].nodeType === e[0].COMMENT_NODE) {
with (e[0].parentElement.insertBefore(document.createElement(e[0].parentElement.tagName == "P" ? "span" : "p"), e[0])) {
e[0].parentNode.removeChild(e[0]);
with (style) {
fontStyle = "italic";
color = "#008000";
whiteSpace = "pre-line";
}
innerHTML = " " + e[0].data.replace(/<(?!\/?(a|b|strong|i|em|p|u|span|div)(\s|>))([^>]*)>/g, "<$3>") + " ";
}
} else {
if (e[0].style && !e[0].classList.contains("cooked")) with (e[0].style) if (display == "none" || visibility && visibility != "visible") {
display = "";
visibility = "visible";
fontStyle = "italic";
color = "#008000"
}
if (e[0].hasChildNodes()) for (i = 0; i < e[0].childNodes.length; e.push(e[0].childNodes[i++]));
}
}
}
parse();
function checkFonts() {
window.removeEventListener('load', checkFonts);
// make sure that every font-family style that uses Open Sans has a fallback for sans-serif
// otherwise, the default serif font would be used... sans-serif is a better fallback font
// while Open Sans is loading, the default font is used, and it's way more annoying to see
// it suddenly change from Times New Roman to Open Sans than if the fallback is sans-serif
for (var i = 0; i < document.styleSheets.length; ++ i) {
var s = document.styleSheets[i], cssRules;
try {
// unfortunately, there's no sure way to tell if this will crash without just trying it...
cssRules = s.cssRules;
} catch (securityError) {
// unable to modify third-party stylesheet -- just leave cssRules undefined to skip it
}
if (cssRules) try {
for (var j = 0; j < cssRules.length; ++ j) {
if (cssRules[j].type == cssRules[j].STYLE_RULE) {
if ((cssRules[j].style.fontFamily || "").indexOf("Open Sans") >= 0) {
s.insertRule(cssRules[j].cssText.replace(/([{;]\s*font-family:(\s*("[^"]*"|'[^']*'|[^,;]*),?\s*)*;)/, (t) => (
t.replace(/((["']?)Open Sans\2\s*(,\s*(?!(["']?)sans-serif\4)[^,;]+)*)(,\s*(["']?)sans-serif\6\s*)?/, "$1,sans-serif"))), j);
s.deleteRule(j + 1);
}
}
}
} catch (e) {
throw e;
}
}
}
if (document.readyState == 'complete') checkFonts();
else window.addEventListener('load', checkFonts);
// detect NodeBB and do the forum-specific stuff
if (document.querySelector("#panel #content")) {
unsafeWindow.eval(`(${function () {
try {
// each topic has a list of vote directions; of that list, one will be selected randomly
// if the randomly selected direction is neither 'up' nor 'down', the post won't be voted
// JS expressions are allowed; the result of the expression will be used
var likesTopics = {
11847: ['up'], // likes topic
19203: ['down'], // downvotes topic
22552: ['up', 'down'], // vote balance topic
22676: [post => ['down', 'up'][post.votes ? +(post.votes > 0) : Math.floor(Math.random() * 2)]] // unstable vote topic
};
var ignored_topic_names = [
// upvote notifications from these topics will be ignored
// (upvote notifications contain the topic name instead of the thread id)
"The Official Likes Topic",
"The official vote balance topic",
"The Official Unstable Vote Equilibrium Topic"
];
var invalidSession = false;
function vote(post, dir) {
try {
if (app.user && (post.uid == app.user.uid || post.username == app.user.username)) {
console.log('Self-vote');
} else {
if (typeof dir == 'function') dir = dir(post);
if (dir == 'up' || dir == 'down') {
socket.emit('posts.' + dir + 'vote', {pid: post.pid, room_id: 'topic_' + post.tid}, (e, d) => {
if (e) {
if (e.message == '[[error:invalid-session]]') {
ajaxify.data.loggedIn = false;
invalidSession = true;
} else if (e.message != "[[error:already-voting-for-this-post]]") {
console.log(e);
}
}
});
} else if (dir) {
throw dir;
}
}
} catch (e) {
console.log('Invalid direction: ' + dir);
}
}
var loggedInStyle;
function setLoggedInUID(uid) {
if (!loggedInStyle) loggedInStyle = document.head.appendChild(document.createElement('style'));
loggedInStyle.innerHTML = `.plugin-mentions-a[href$="/uid/${uid}"] {
background: rgba(255, 255, 0, 0.4);
box-shadow: 0 0 2px rgba(255, 255, 0, 0.4);
border-radius: 0.5em;
}`;
}
var e = document.getElementById('ajaxify-data');
if (e) {
e = JSON.parse(e.innerText);
if (e.loggedInUser) setLoggedInUID(e.loggedInUser.uid);
}
// monkey-patch socket.onevent to process incoming events before they go to the default event handler(s)
var real_onevent = socket.onevent.bind(socket);
socket.onevent = function onevent(packet) {
try {
var eventName = packet.data[0], n = packet.data[1];
var preventDefault = false;
if (eventName == 'checkSession' || !loggedInStyle && ajaxify.data && ajaxify.data.loggedInUser) {
setLoggedInUID(eventName == 'checkSession' ? n : ajaxify.data.loggedInUser.uid);
}
if (eventName == 'event:new_notification') {
// automatically mark upvote notifications from the "Likes" thread as read
if (/^upvote/.test(n.nid) && (ignored_topic_names.indexOf(n.topicTitle) >= 0 || n.bodyShort.indexOf('[[notifications:upvoted_your_post_in, Tsaukpaetra, ') == 0)) {
console.log('Ignoring: ' + n.nid + ', ' + n.bodyShort);
socket.emit('notifications.markRead', n.nid, function (e) { if (e) console.log(e); });
preventDefault = true; // prevents NodeBB's event handlers from even seeing this event
}
// automatically downvote any posts that mention trust_level_3
if (n.bodyShort.indexOf('[' + '[notifications:user_mentioned_group_in,') == 0 && /:trust_level_3$/.test(n.nid)) {
console.log(n);
console.log('Downvoting trust_level_3 mention (post ' + n.pid + (n.user ? ' by @' + n.user.username : '') +
' in "' + n.bodyShort.slice(2, -2).split(', ').slice(3).join(', ') + '")');
vote(n, 'down');
}
}
if (eventName == 'event:new_post') {
if (ajaxify.data && ajaxify.data.loggedIn) {
n.posts.forEach(post => {
var dirs = likesTopics[post.tid];
// users to downvote automatically, immediately
if (post.uid == 46) dirs = ['down']; // him of whom we do not speak
if (post.uid == 21 && post.tid == 21834) dirs = ['down']; // lorne in his garage-non-garage nazi thread
if (dirs) vote(post, dirs[Math.floor(Math.random() * dirs.length)]);
});
}
}
if (!preventDefault) real_onevent(packet);
} catch (e) {
console.error("socket.onevent:", e);
}
};
// monkey-patch socket.emit to process sent/received data before it goes to the default event handler(s)
var real_emit = socket.emit, emit_queue = [], socket_connected = true;
socket.emit = function emit(eventName) {
if (!socket.connected && !/connect/.test(eventName)) {
console.log(`Socket isn't connected! Delaying emit ${eventName}...`);
emit_queue.push([...arguments]);
socket_connected = false;
socket.connect();
var t, reconnected = function reconnected(connected) {
socket.off('connect', reconnected);
clearTimeout(t);
if (!connected) {
console.log("Socket still isn't connected. Disconnecting code hook!");
socket.emit = real_emit;
socket.emit(...arguments);
console.log("Dumping emit queue:\n" + emit_queue.map(e => ' ' + e[0]).join('\n'));
emit_queue = [];
} else {
if (!socket_connected) console.log("Socket reconnected.");
socket_connected = true;
if (emit_queue.length) {
var args = emit_queue.shift();
console.log("Emitting delayed message " + args[0]);
socket.emit(...args);
}
}
};
socket.on('connect', () => { reconnected(true); });
t = setTimeout(() => { reconnected(false); }, 10000);
} else try {
if (eventName == 'disconnect') {
console.log('Trap! Refused:', [...arguments].toSource());
setTimeout(() => socket.connect(), 500);
return;
}
var rest = [].slice.call(arguments, 1);
var preventDefault = false;
if (eventName == 'notifications.get') {
// when getting the notification list, filter out upvotes from the Likes topic (and mark them read if they're not)
var i = rest.length - 1, orig_fn = rest[i];
if (typeof orig_fn == 'function') {
rest[i] = function (err, obj) {
try {
if (err == null) {
function filter(n) {
try {
// ignore @Tsaukpaetra's upvotes, too -- he upvotes EVERY POST... :doing-it-wrong:
if (/^upvote/.test(n.nid) && (ignored_topic_names.indexOf(n.topicTitle) >= 0 || n.bodyShort.indexOf('[[notifications:upvoted_your_post_in, Tsaukpaetra, ') == 0)) {
if (!n.read) socket.emit('notifications.markRead', n.nid, e => e ? console.log(e) : 0);
return false;
}
return true;
} catch (e) {
console.error('socket.emit callback filter:', e);
}
}
obj.unread = obj.unread.filter(filter);
obj.read = obj.read.filter(filter);
}
orig_fn(err, obj);
setTimeout(() => {
if (emit_queue.length) {
var args = emit_queue.shift();
console.log("Emitting delayed message " + args[0]);
socket.emit(...args);
}
}, 1000);
} catch (e) {
console.error('socket.emit callback:', e);
}
};
}
}
if (!preventDefault) real_emit.apply(socket, [eventName].concat(rest));
} catch (e) {
console.error("socket.emit:", e);
}
};
// this will load the list of users in a specified group (one page at a time until the whole group is loaded)
// it's not currently used, but since I wrote it, I'll keep it around in case I ever do want to use it...
// (@by-joining-this-group-you-agree-to-be-mentioned-randomly-for-no-reason-is-that-okay-yes-no :evil_grin:)
function loadGroup(group_name, callback) {
try {
if (typeof callback != "function") callback = () => {};
var req = new XMLHttpRequest, ret = {incomplete: true, group: null};
req.open('GET', '/api/groups/' + group_name.toLowerCase().replace(/[-\s]+/g, '-'));
req.addEventListener('load', () => {
try {
ret.group = JSON.parse(req.responseText).group;
} catch (e) {
delete ret.incomplete;
ret.error = req.responseText;
callback(null);
return;
}
try {
var next_start = ret.group.membersNextStart;
delete ret.group.membersNextStart;
(function loadMoreGroupMembers(start) {
try {
socket.emit('groups.loadMoreMembers', {groupName: ret.group.name, after: start}, (e, a) => {
try {
if (e) {
delete ret.incomplete;
ret.error = e;
console.error(e);
console.log(ret.group);
callback(null);
} else {
if (a.users.length) {
ret.group.members = ret.group.members.concat(a.users);
loadMoreGroupMembers(a.nextStart);
} else {
delete ret.incomplete;
callback(ret.group);
}
}
} catch (e) {
console.error('loadMoreGroupMembers callback:', e);
}
});
} catch (e) {
console.error('loadMoreGroupMembers:', e);
}
})(next_start);
} catch (e) {
console.error('loadGroup onload:', e);
}
});
req.send();
return ret;
} catch (e) {
console.error("loadGroup:", e);
}
}
// this (hopefully) should prevent my post caching from getting duplicate versions cached...
// it tries to normalize out inconsistencies in loaded state (img-responsive), and things like
// the HTML attributes or CSS classes not always occuring in the same order, and other things
// that can change depending on when and how the post was loaded (streamed in vs. page loaded)
function normalize(post) {
try {
var d = document.createElement('div'), a = document.createElement('a');
d.innerHTML = post.trim();
d.classList.add('content');
// add the fa-angle-down toggle to show/hide contents of 2nd level blockquotes
// when they load initially, they have it already; it's only missing when they stream in
// this shit wouldn't be necessary if it baked posts completely before streaming them in, ffs.
[...d.querySelectorAll('.content > blockquote > blockquote')]
.forEach(e => !e.querySelector('.toggle') && e.appendChild(document.createElement('i'))
.classList.add('fa', 'fa-angle-down', 'pointer', 'toggle'));
// do the stupid image responsive loading dance, since streamed-in posts dont have that either
[...d.querySelectorAll('img:not(.not-responsive):not([data-state])')].forEach(e => {
var a = e.parentElement.insertBefore(document.createElement('a'), e);
a.appendChild(e).setAttribute('data-state', 'loaded');
e.classList.add('img-responsive');
a.href = e.src;
a.target = '_blank';
});
[...d.querySelectorAll('img.img-responsive:not([data-state="loaded"])')]
.forEach(i => i.setAttribute('data-state', 'loaded'));
[...d.querySelectorAll('*')].forEach(e => {
// sort the attribute list for consistency, and remove any that are blank...
// this is necessary because I'm using string comparison to do a diff on HTML (:doing_it_wrong:)
// it assumes that, if I remove all of the attributes and put them back in a particular order,
// they'll stay in THAT order, OR they'll be in some canonical order preferred by the browser
// ...either way, the results should be repeatable. I hope.
[...e.attributes].map(a => a.name).sort().forEach(n => {
var t = e.getAttribute(n);
e.removeAttribute(n);
if (t) e.setAttribute(n, t);
});
// sort the classList for consistency... same sort of thing as sorting the attributes
if ('classList' in e && e.classList.length) {
e.className = [...e.classList].sort().join(' ');
}
// convert full URLs (https://what.thedailywtf.com/...) to relative (/...) where applicable
if ('href' in e.attributes && e.getAttribute('href').indexOf(location.origin) == 0) {
// also remove "-resized" from oneboxed image upload link filenames -- it sometimes has it, and sometimes does not
e.href = (a.href = e.href, a).pathname.replace(/^(\/assets\/uploads\/files\/.*)-resized(\.\w+)?$/, '$1$2') + a.search + a.hash;
}
if ('src' in e.attributes && e.getAttribute('src').indexOf(location.origin) == 0) {
e.src = (a.href = e.src, a).pathname + a.search + a.hash;
}
});
d.normalize();
return d.innerHTML;
} catch (e) {
console.error('normalize:', e);
}
}
window.mem_postCache = [];
function merge_mem_postCache() {
// merges the localStorage's mem_postCache object with the one in memory
// the mem_postCache object in memory is mutated in place; the offline verison is removed
try {
var v = localStorage.getItem('mem_postCache');
localStorage.removeItem('mem_postCache');
if (v) {
JSON.parse(v).forEach(p => {
p.expires = new Date(p.expires);
window.mem_postCache.push(p);
});
window.mem_postCache.sort((a, b) => b.pid < a.pid);
for (var i = 1; i < window.mem_postCache.length; ++ i) {
if (window.mem_postCache[i].pid == window.mem_postCache[i - 1].pid) {
window.mem_postCache.splice(i, 1);
-- i;
}
}
}
return window.mem_postCache;
} catch (e) {
console.error('merge_mem_postCache:', e);
}
}
var noFloodingDelay = 5000, filter_mem_postCache_running = false;
function filter_mem_postCache() {
// filters out expired posts, checks to see if they're deleted and if so, commits them to permanent cache
// mem_postCache is mutated in place, but the expired posts are removed asynchronously as the checks are done
if (invalidSession || !ajaxify.data || ajaxify.data.loggedIn == false) {
console.error('No session - aborting filter_mem_postCache');
return;
}
if (filter_mem_postCache_running) {
console.warn('filter_mem_postCache already running... not starting a new instance');
return;
}
filter_mem_postCache_running = true;
merge_mem_postCache();
try {
var d = new Date, a = window.mem_postCache.filter(c => d >= c.expires);
(function check() {
try {
if (a.length) {
var p = a.shift();
console.log('Checking old post (memory cache) ' + p.pid + '...');
socket.emit('posts.getRawPost', p.pid, ((e, a) => {
try {
for (var post, i = 0; i < window.mem_postCache.length; ++ i) {
if (window.mem_postCache[i].pid == p.pid) {
post = window.mem_postCache[i];
window.mem_postCache.splice(i, 1);
i = window.mem_postCache.length;
}
}
if (e && e.message == "[[error:no-post]]") {
console.log(`Forcing cache of post (pid: ${post.pid}) -- post has been deleted:\n${post.content}`);
addPostToCache({post: post}, true);
} else if (e && e.message == "[[error:no-privileges]]") {
console.log(`No privileges for post (pid: ${post.pid} -- binned as spam?):\n${post.content}`);
} else if (e) {
console.log(JSON.stringify(e));
alert(JSON.stringify(e));
}
} catch (e) {
console.error('filter_mem_postCache callback:', e);
}
}));
setTimeout(check, noFloodingDelay);
} else {
filter_mem_postCache_running = false;
}
} catch (e) {
console.error('filter_mem_postCache check', e);
filter_mem_postCache_running = false;
}
})();
} catch (e) {
console.error('filter_mem_postCache', e);
filter_mem_postCache_running = false;
}
}
// commit memory post cache to localStorage on page unload, so that the cached posts in it aren't lost
// they'll have to be filtered on the next visit, since that can only be done asynchronously...
// the unload event has no way of asking the page wait for that to complete
window.addEventListener('unload', () => {
real_emit.call(socket, 'disconnect', 'transport close');
// cannot call filter_mem_postCache here; it's asynchronous, so the page would unload before it finished
merge_mem_postCache();
if (window.mem_postCache.length) {
localStorage.setItem('mem_postCache', JSON.stringify(window.mem_postCache, (k, v) => {
if (typeof v == "object") Object.keys(v).forEach(p => {
if (v[p] instanceof Date) v[p] = +v[p]; // use integer dates -- they'll be passed back into new Date anyway
});
return v;
}));
}
});
setTimeout(filter_mem_postCache, 5000);
setInterval(filter_mem_postCache, 15 * 60 * 1000); // clean up the memory post cache every 15 minutes
function addPostToCache(postData, force) {
try {
var watchedUIDs = [
/* ALL posts are cached for 2 hours now, so these probably aren't necessary... */
140870, // fbmac
140925, // candlejack1
141518, // clippy
141534, // otter
141547, // groo
141551, // wharrgarbl
141843 // sockpuppet7
];
(postData.posts || [postData.post]).forEach(post => {
try {
if (force || watchedUIDs.includes(+post.uid)) {
// normalize the HTML...
post.content = normalize(post.content);
var n = [`pid:${post.pid}:cache`, `pid:${post.pid}:cached`];
if (force) n.reverse();
var versions = localStorage.getItem(n[0]) || localStorage.getItem(n[1]);
localStorage.removeItem(n[1]);
if (versions) {
versions = JSON.parse(`[${versions}]`);
if (versions.indexOf(post.content) < 0) {
versions = versions.filter(v =>
post.content.split('').join('')
!= v.split('').join('')
);
versions = JSON.stringify(versions.concat(post.content)).slice(1, -1);
localStorage.setItem(n[0], versions);
}
} else {
localStorage.setItem(n[0], JSON.stringify([post.content]).slice(1, -1));
}
} else if (post.timestamp && (!app.user || post.uid != app.user.uid)) {
// cache ALL posts in memory for 2 hours -- they'll only be committed to localStorage if the post gets deleted
var p = {uid: post.uid, pid: post.pid, content: post.content, expires: new Date(+post.timestamp + 2 * 60 * 60 * 1000)};
//console.log("mem_postCache:", p);
window.mem_postCache.push(p);
}
} catch (e) {
console.error('addPostToCache forEach:', e);
console.log(post);
}
});
} catch (e) {
console.error('addPostToCache:', e);
}
}
// any cached posts from more than 8 days ago, which were not already flagged as deleted, will be checked
// if they're deleted, they'll be flagged as deleted and kept forever; otherwise, they'll be purged from the cache
// the limit for post deletion is 7 days, so they don't need to be kept for longer unless they're already deleted
// I could keep them around for longer, but they do clutter up the localStorage, and it's not infinite in size...
function cleanup_cache() {
console.log('Initiating localStorage cache cleanup...');
try {
if (invalidSession || !ajaxify.data || ajaxify.data.loggedIn == false) {
console.error('No session - aborting cleanup_cache');
return;
}
var callback;
var a = [], d = new Date(+new Date - 8 * 24 * 60 * 60 * 1000);
for (var p in localStorage)
if (/^pid:.*:cache$/.test(p))
a.push(+p.match(/\d+/));
a.sort((a, b) => a - b);
(function check([pid, ...arr]) {
try {
if (pid) {
if (!socket.connected) {
console.log("socket disconnected - this shouldn't happen anymore");
alert("socket disconnected - this shouldn't happen anymore");
}
/*var t = setTimeout(() => {
if (!socket.connected) socket.connect(); // why??!?
setTimeout(() => { check([pid, ...arr]); }, 500);
}, 500);*/
console.log('Checking old post (localStorage cache) ' + pid + '...');
socket.emit('posts.getPost', pid, (e, p) => {
try {
//clearTimeout(t);
if (p) {
if (e || (p.deleted && (!app.user || p.uid != app.user.uid))) {
var v = localStorage.getItem(`pid:${pid}:cache`);
if (v) {
console.log('Post', pid, 'is now deleted -- moving it to deleted posts');
localStorage.setItem(`pid:${pid}:cached`, v);
localStorage.removeItem(`pid:${pid}:cache`);
}
setTimeout(() => { check(arr); }, noFloodingDelay);
} else if (new Date(p.timestamp) < d) {
console.log('Post', pid, 'is old... removing from cache');
localStorage.removeItem(`pid:${pid}:cache`);
setTimeout(() => { check(arr); }, noFloodingDelay);
} else {
// post is too new -- stop
console.log('localStorage cache cleanup complete.');
if (callback) callback();
}
} else {
console.log('cleanup_cache - p is undefined');
if (callback) callback();
}
} catch (e) {
console.error('cleanup_cache callback:', e);
}
});
} else {
console.log('localStorage cache cleanup complete.');
if (callback) callback();
}
} catch (e) {
console.error('cleanup_cache check:', e);
}
})(a);
} catch (e) {
console.error('cleanup_cache:', e);
}
return {then: fn => { callback = fn; }};
}
setTimeout(() => { try {
cleanup_cache().then(function beginCleanup() {
setTimeout(() => { cleanup_cache().then(beginCleanup); }, 4 * 60 * 60 * 1000);
});
} catch (e) { console.error(e); } }, 30 * 1000);
// this takes a string of raw HTML and works similar to innerText but also adds text syntax for blockquotes
function convertHTMLtoText(t) {
try {
var e, d = document.createElement('div');
d.innerHTML = t;
while (e = d.querySelector('blockquote blockquote blockquote'))
e.parentElement.replaceChild(document.createTextNode('> [v]\n'), e);
while (e = [...d.querySelectorAll('blockquote')].pop())
e.parentElement.replaceChild(document.createTextNode(e.innerText.trim().replace(/^/gm, '> ') + '\n'), e);
return d.innerText.trim();
} catch (e) {
console.error('convertHTMLtoText:', e);
}
}
socket.on('event:new_post', (a) => {
if (a.posts) a.posts.forEach(p => { if (p.index < 2) console.log('event:new_post', p); });
else console.log(a);
addPostToCache(a);
});
socket.on('event:new_topic', (...a) => console.log(...a)); // a new topic (does|doesn't) trigger the "new_post" event?
socket.on('event:post_edited', addPostToCache);
socket.on('event:post_deleted', (...a) => console.log('event:post_deleted', ...a));
// Automatic refresh -- this helps keep the socket alive while in the background
// Otherwise, it tends to decide that minor details like streaming in new posts are no big
setInterval(() => {
var req = new XMLHttpRequest;
req.open('GET', '/api/' + ajaxify.currentPage);
req.send();
}, 30000);
setInterval(function checkLikesThreads() {
if (ajaxify.data.loggedIn) {
try {
var likesThreadBookmarks = JSON.parse(localStorage.getItem('likes_thread_bookmarks') || "{}");
function catchUp(tid, bookmark, dirs) {
try {
var req = new XMLHttpRequest;
req.addEventListener('load', () => {
try {
var topic = JSON.parse(req.responseText), b = bookmark;
topic.posts.forEach(post => {
try {
bookmark = Math.max(bookmark, post.index + 1);
var dir = dirs[Math.floor(Math.random() * dirs.length)];
if (!post.upvoted && !post.downvoted && !post.selfPost)
vote(post, dir);
} catch (e) {
console.error('checkLikesThreads catchUp forEach:', e);
}
});
likesThreadBookmarks[tid] = bookmark;
localStorage.setItem('likes_thread_bookmarks', JSON.stringify(likesThreadBookmarks));
if (bookmark < topic.postcount) {
catchUp(tid, bookmark + 1, dirs);
}
} catch (e) {
console.log('checkLikesThreads catchUp onload:', e);
}
});
req.open('GET', `/api/topic/${tid}/-/${bookmark}`);
req.send();
} catch (e) {
console.error('checkLikesThreads catchUp', e);
}
}
Object.keys(likesTopics).forEach(tid => {
try {
if (tid in likesThreadBookmarks) {
catchUp(tid, likesThreadBookmarks[tid], likesTopics[tid]);
} else {
var req = new XMLHttpRequest;
req.addEventListener('load', () => {
try {
likesThreadBookmarks[tid] = JSON.parse(req.responseText).bookmark;
localStorage.setItem('likes_thread_bookmarks', JSON.stringify(likesThreadBookmarks));
catchUp(tid, likesThreadBookmarks[tid], likesTopics[tid]);
} catch (e) {
console.error('checkLikesThreads forEach onload:', e);
}
});
req.open('GET', `/api/topic/${tid}/`);
req.send();
}
} catch (e) {
console.error('checkLikesThreads forEach:', e);
}
});
} catch (e) {
console.error('checkLikesThreads:', e);
}
}
}, 5 * 60 * 1000);
function getVersion(v, post, cache) {
try {
var s = post.querySelector('.post-header .versions');
var m = s.getAttribute('data-max-version');
for (var i = 0, t = s.childNodes[i]; i < s.childNodes.length && t.nodeType != t.TEXT_NODE; t = s.childNodes[++ i]);
if (v > 0 && v <= m) {
var content = post.querySelector('.content:not(.raw-content)'),
raw_content = post.querySelector('.content.raw-content');
content.innerHTML = cache[v - 1];
if (raw_content) raw_content.innerHTML = 'Raw version not cached!';
t.data = ' ' + v + ' / ' + cache.length + ' ';
s.setAttribute('data-version', v);
s.classList.toggle('last-version', v == m);
}
} catch (e) {
console.error('getVersion:', e);
}
}
function onPostsLoaded() {
try {
[...document.querySelectorAll('.topic .posts > li:not(.deleted)')].forEach(post => {
try {
var uid = post.getAttribute('data-uid'), pid = post.getAttribute('data-pid');
addPostToCache({
post: {
uid: uid,
pid: pid,
content: post.querySelector('.content:not(.raw-content)').innerHTML
}
});
var cache = localStorage.getItem(`pid:${pid}:cache`) || localStorage.getItem(`pid:${pid}:cached`);
if (cache) {
cache = JSON.parse(`[${cache}]`);
if (cache.length > 1) {
var div = post.querySelector('.permalink').parentElement;
var s = div.querySelector('.versions');
if (s) div.removeChild(s);
s = document.createElement('span');
s.classList.add('versions');
div.appendChild(s);
var e = document.createElement('i');
e.classList.add('fa', 'fa-chevron-left');
e.addEventListener('click', () => {
getVersion(s.getAttribute('data-version') - 1, post, cache);
});
s.appendChild(e);
var t = document.createTextNode(' ' + cache.length + ' / ' + cache.length + ' ');
s.appendChild(t);
e = document.createElement('i');
e.classList.add('fa', 'fa-chevron-right');
e.addEventListener('click', () => {
getVersion(+s.getAttribute('data-version') + 1, post, cache);
});
s.appendChild(e);
e = document.createElement('i');
e.classList.add('fa', 'fa-trash');
e.addEventListener('click', () => {
if (!s.classList.contains('last-version')) {
if (confirm('Are you sure you want to delete this version?')) {
var i = s.getAttribute('data-version') - 1;
cache.splice(i, 1);
localStorage.removeItem(`pid:${pid}:cached`);
localStorage.setItem(`pid:${pid}:cache`, JSON.stringify(cache).slice(1, -1));
s.setAttribute('data-max-version', cache.length);
getVersion(i + 1, post, cache);
}
}
});
s.appendChild(e);
s.setAttribute('data-version', cache.length);
s.setAttribute('data-max-version', cache.length);
s.classList.add('last-version');
}
}
} catch (e) {
console.error('onPostsLoaded forEach:', e);
}
});
[...document.querySelectorAll('video:not([controls])')].forEach(video => {
video.setAttribute('controls', '');
});
/*if (ajaxify.data.loggedInUser) [...document.querySelectorAll('a.plugin-mentions-a')].forEach(e => {
if (e.innerText == "@" + ajaxify.data.loggedInUser.username) {
e.classList.add('highlight');
}
});*/
} catch (e) {
console.error('onPostsLoaded:', e);
}
}
$(window).on('action:topic.loaded', onPostsLoaded);
$(window).on('action:posts.loaded', onPostsLoaded);
function generateCategoryMenu(categories, level) {
try {
var categoryList = document.createElement('ul'), t = document.createElement('i');
if (!level) categoryList.classList.add('categories', 'category-list-bar');
for (cat of categories) {
var el = categoryList.appendChild(document.createElement('li'));
el.classList.add(level ? "subcategory" : "category");
var a = el.appendChild(document.createElement('a'));
a.classList.add('content');
a.setAttribute('href', `/category/${cat.slug}`);
a.setAttribute('title', (t.innerHTML = cat.description, t.textContent));
var h = document.createElement(level ? 'small' : 'h2');
if (level) {
var s = a.appendChild(document.createElement('span'));
s.classList.add('fa-stack', 'fa-lg');
var i = s.appendChild(document.createElement('i'));
i.classList.add('fa', 'fa-circle', 'fa-stack-2x');
i.style.color = cat.bgColor;
i = s.appendChild(document.createElement('i'));
i.classList.add('fa', 'fa-stack-1x', cat.icon);
i.style.color = cat.color;
} else {
var d = a.appendChild(document.createElement('div'));
d.classList.add('icon', 'pull-left');
d.style.backgroundColor = cat.bgColor;
d.style.color = cat.color;
var i = d.appendChild(document.createElement('i'));
i.classList.add('fa', 'fa-fw', cat.icon);
h.classList.add('title')
}
a.appendChild(h).appendChild(document.createTextNode((t.innerHTML = cat.name, t.textContent)));
if (cat.children.length) el.appendChild(generateCategoryMenu(cat.children, level + 1 || 1));
}
return categoryList;
} catch (e) {
console.error('generateCategoryMenu:', e);
}
}
var refreshButton = document.querySelector('#main-nav').appendChild(document.createElement('li'));
refreshButton = refreshButton.appendChild(document.createElement('a'));
refreshButton.appendChild(document.createElement('i')).className = 'fa fa-fw fa-refresh';
refreshButton.href = '#';
refreshButton.addEventListener('mouseover', () => refreshButton.querySelector('i').classList.add('fa-spin'));
refreshButton.addEventListener('mouseout', () => refreshButton.querySelector('i').classList.remove('fa-spin'));
refreshButton.addEventListener('click', function refresh(e) {
ajaxify.refresh();
e.preventDefault();
});
socket.emit('categories.getCategoriesByPrivilege', 'topics:create', function (err, categories) {
if (err) {
app.alertError(err.message);
} else {
var cids = [];
categories = categories.filter(function filter(cat) {
var i = cids.indexOf(cat.cid);
if (i < 0) {
cids.push(cat.cid);
cat.children = cat.children.filter(filter);
}
return i < 0;
});
}
var t, u;
function hide() {
if (u) u = clearTimeout(u);
if (!t) t = setTimeout(() => {
if (!u) {
categoryMenu.style.maxHeight = categoryMenu.style.opacity = '0';
var e = document.querySelector('[component="navbar/title"]');
if (e) e.style.maxHeight = null;
}
t = null;
}, 100);
}
function show() {
if (t) t = clearTimeout(t);
if (!u) u = setTimeout(() => {
if (!t) {
categoryMenu.style.maxHeight = categoryMenu.scrollHeight + 'px';
categoryMenu.style.opacity = '1';
var e = document.querySelector('[component="navbar/title"]');
if (e) e.style.maxHeight = '0';
}
u = null;
}, 500);
}
var categoryMenu = generateCategoryMenu(categories);
document.getElementById('header-menu').appendChild(categoryMenu);
document.getElementById('header-menu').addEventListener('mouseleave', hide);
document.getElementById('header-menu').addEventListener('mousemove', e0 => {
var x = (2 * e0.clientX / categoryMenu.offsetWidth - 1) * Math.PI;
x = Math.max(0, Math.min(1, Math.sin(x / 2) * (Math.cos(x) / 8 + .625) + .5));
categoryMenu.scrollTo(x * (categoryMenu.scrollWidth - categoryMenu.offsetWidth), 0);
if (!document.querySelector('[data-original-title="Categories"]:hover') && e0.target.matches)
if (!categoryMenu.contains(e0.target) && (e0.target.matches('a') || e0.target.closest('a')))
hide();
});
document.getElementById('header-menu').addEventListener('click', e1 => {
if (categoryMenu.offsetHeight > 1 && (e1.target.matches('a') || e1.target.closest('a'))) {
categoryMenu.style.maxHeight = categoryMenu.style.opacity = null;
hide();
}
});
categoryMenu.style.maxHeight = categoryMenu.style.opacity = '0';
categoryMenu.addEventListener('mouseleave', hide);
categoryMenu.addEventListener('mouseenter', () => {
if (categoryMenu.offsetHeight > 1) show();
});
new MutationObserver(function () {
if (document.querySelector('[data-original-title="Categories"]:hover'))
show();
}).observe(document.getElementById('header-menu'), {childList: true, subtree: true});
});
var isDetailsSupported = (() => {
try {
var d = document.createElement('details');
if (!('open' in d)) return false;
var p = d.appendChild(document.createElement('p'));
p.appendChild(document.createTextNode('?'));
document.body.appendChild(d);
var h = p.offsetHeight;
document.body.removeChild(d);
return !h;
} catch (e) {
console.error('eval isDetailsSupported:', e);
}
})();
function addButtons(m) {
try {
function updateBookmark() {
try {
var e = +this.closest('li[component="post"]').getAttribute('data-index') + 1;
socket.emit('topics.bookmark', {tid: ajaxify.data.tid, index: e}, function (t) {
if (t) console.error(t.message);
else ajaxify.data.bookmark = e;
// also mark the thread as unread if there are still unread posts
if (ajaxify.data.bookmark < ajaxify.data.postcount) {
document.querySelector('[component="topic/mark-unread"]').click();
}
});
} catch (e) {
console.error('updateBookmark:', e);
}
}
[...document.querySelectorAll('.post-tools')].forEach(e => {
try {
e = e.closest('.post-footer');
var favOption = e.querySelector('[component="post/bookmark"]');
if (favOption) {
favOption = favOption.closest('li');
if (!e.querySelector(".mark-unread")) {
var markUnread = favOption.parentElement.insertBefore(document.createElement("li"), favOption.nextSibling);
markUnread.setAttribute("role", "presentation");
var a = markUnread.appendChild(document.createElement("a"));
a.appendChild(document.createTextNode("Mark unread from here"));
a.setAttribute("role", "menuitem");
a.setAttribute("href", "#");
a.classList.add("mark-unread");
a.addEventListener("click", updateBookmark);
}
}
} catch (e) {
console.error('addButtons .post-tools forEach:', e);
}
});
// replace deleted posts if they're cached
[...document.querySelectorAll('li.deleted:not(.cached):not(.not-cached)')].forEach(post => {
try {
var pid = post.getAttribute('data-pid');
var cached = localStorage.getItem(`pid:${pid}:cached`);
if (!cached) {
cached = localStorage.getItem(`pid:${pid}:cache`) ||
[(window.mem_postCache || []).find(p => p.pid == pid)].map(p => p ? JSON.stringify(p.content) : p)[0];
if (cached) {
localStorage.removeItem(`pid:${pid}:cache`);
localStorage.setItem(`pid:${pid}:cached`, cached);
}
}
if (cached) {
post.classList.add("cached");
var content = post.querySelector('.content:not(.raw-content)');
var raw_content = post.querySelector('.content.raw-content');
var versions = JSON.parse(`[${cached}]`);
var v = versions.reduce((a, b) => a.length * .8 > b.length ? a : b, "");
content.innerHTML = v;
if (raw_content) raw_content.innerHTML = "Raw version not cached!";
} else {
post.classList.add("not-cached");
}
} catch (e) {
console.error('addButtons .deleted forEach:', e);
}
});
// add a shim for and if the browser doesn't support them
if (!isDetailsSupported) {
try {
var d, s;
while (d = document.querySelector('details')) {
var open = d.hasAttribute('open') || d.getAttribute('data-open') == 'open';
var label = document.createElement('label');
label.classList.add('details');
label.addEventListener('click', e => {
if (!e.target.matches('.details > input:first-child, .details > .summary')) {
var i = e.target.closest('.details').querySelector('input:first-child');
i.setAttribute('disabled', 'true');
setTimeout(() => i.removeAttribute('disabled'), 10);
e.stopPropagation();
}
});
var i = label.appendChild(document.createElement('input'));
i.setAttribute('type', 'checkbox');
if (open) i.setAttribute('checked', '');
[...d.children]
.filter(e => e.matches('summary'))
.forEach(e => label.appendChild(e));
if (!label.querySelector('summary'))
label.appendChild(document.createElement('summary'))
.appendChild(document.createTextNode('Details'));
s = label.appendChild(document.createElement('div'));
while (d.firstChild) s.appendChild(d.firstChild);
d.parentElement.insertBefore(label, d);
d.parentElement.removeChild(d);
label.parentElement.normalize();
}
while (s = document.querySelector('.details > summary')) {
var span = document.createElement('span');
span.classList.add('summary');
span.innerHTML = s.innerHTML;
s.parentElement.insertBefore(span, s);
s.parentElement.removeChild(s);
}
} catch (e) {
console.error(e);
}
}
} catch (e) {
console.error('addButtons:', e);
}
}
var observer = new MutationObserver(addButtons);
observer.observe(document.body, {childList: true, subtree: true});
addButtons();
} catch (e) {
console.error(e);
}
}})`)();
styleSheet.innerHTML += `
.versions .fa {
padding: 6px;
cursor: pointer;
}
.versions[data-version='1'] .fa-chevron-left, .versions.last-version .fa-chevron-right, .versions.last-version .fa-trash {
color: #aaa;
cursor: default;
}
.versions[data-max-version='1'] {
transition: linear 2s;
opacity: 0;
}
.navbar.header {
-moz-user-select: none;
}
[data-original-title="Categories"] ~ .tooltip {
display: none !important;
}
.category-list-bar {
width: 100%;
overflow: hidden;
background: #fff;
position: absolute;
white-space: nowrap;
border-bottom: solid 1px #eee;
transition: linear .25s;
}
.category-list-bar li {
display: inline-block;
vertical-align: top;
margin: 0 5px;
padding: 0;
}
.categories.category-list-bar > li .content h2 {
margin-left: 55px;
}
.categories.category-list-bar .content .icon {
margin: 0;
position: absolute;
}
.category-list-bar .category > ul {
opacity: 1;
}
.category-list-bar .subcategory {
padding: 0;
margin: 0 0 0 50px;
display: block;
min-height: unset;
}
.category-list-bar .subcategory ul {
padding: 0 0 0 20px;
}
.category-list-bar .subcategory .subcategory {
margin: 0;
}
`;
var isDetailsSupported = (() => {
try {
var d = document.createElement('details');
if (!('open' in d)) return false;
var p = d.appendChild(document.createElement('p'));
p.appendChild(document.createTextNode('?'));
document.body.appendChild(d);
var h = p.offsetHeight;
document.body.removeChild(d);
return !h;
} catch (e) {
console.log('isDetailsSupported:', e);
}
})();
if (!isDetailsSupported) styleSheet.innerHTML += `
/* Add styles for details/summary shim */
.details {
display: block;
font-weight: inherit;
}
.details > .summary {
cursor: pointer;
display: inline-block;
font-weight: inherit;
}
.details > input:first-child {
display: none;
}
.details > input:first-child + :before {
content: '▶';
display: block;
margin-top: 1pt;
font-size: 70%;
width: 1.3em;
float: left;
cursor: pointer;
}
.details > input:first-child:checked + :before {
content: '▼';
margin-left: -2px;
margin-right: 2px;
}
.details > input:first-child:not(:checked) ~ :not(.summary) {
display: none;
}
`;
else styleSheet.innerHTML += `
summary {
display: list-item !important;
}
`;
// apply the "upvoted" or "downvoted" classes to the element of the post, in addition to the itself
// this allows me to apply CSS styles to the contents of the post... muhaha!
new MutationObserver(m => {
var a = [];
m.forEach(e => {
if (e.attributeName == "class" && e.target.tagName == "A" && e.target.parentElement.matches(".votes")) {
a.push(e.target);
} else if (e.addedNodes.length) {
[].forEach.call(e.addedNodes, n => {
if (n.querySelectorAll)
a = [].concat.apply(a, n.querySelectorAll("li:not(.upvoted) .upvoted,li:not(.downvoted) .downvoted"));
});
}
});
a.forEach(e => {
var c = e.getAttribute("component").match(/\w*v.*/) + "d";
e.closest('li[component="post"]').classList.toggle(c, e.classList.contains(c));
});
}).observe(document.getElementById("content"), {attributes: true, childList: true, subtree: true});
// trigger the MutationObserver once to inspect descendants of the .posts element
var p = document.querySelector(".posts");
if (p) p.parentElement.insertBefore(p, p);
// extract YouTube embeds and make them float on the right side of the window if their parent post is unloaded
var s = document.createElement('script');
s.src = "https://www.youtube.com/iframe_api";
document.head.appendChild(s);
unsafeWindow.eval(`${function onYouTubeIframeAPIReady() {
function vote(dir, pid, tid) {
var cb = null;
socket.emit(`posts.${dir}vote`, {pid: pid, room_id: `topic_${tid}`}, (...a) => {
if (a[0]) console.error(a[0]);
if (cb) cb(...a);
});
return {then: fn => { cb = fn; }};
}
console.log('YouTubeIframeAPIReady');
socket.on('event:voted', (vote) => {
var elem = document.querySelector(`.floating-controls-below[data-pid="${vote.post.pid}"]`);
console.log(vote, elem);
if (elem) {
var upvoted = elem.querySelector('.fa-chevron-up').closest('a');
var downvoted = elem.querySelector('.fa-chevron-down').closest('a');
if (vote.upvote) upvoted.classList.add('upvoted');
else upvoted.classList.remove('upvoted');
if (vote.downvote) downvoted.classList.add('downvoted');
else downvoted.classList.remove('downvoted');
}
});
new MutationObserver(m => {
m.reduce((a, e) => a.concat(...e.addedNodes), []).filter(n => n.querySelector).forEach(n => {
[n, ...n.querySelectorAll('iframe')].filter(e => e.matches('.js-lazyYT > iframe')).forEach(function (i) {
i = i.parentElement;
var post = i.closest('[component="post"]');
try {
function f(e) {
var state = p.getAttribute('data-state-name'), orphaned = p.classList.contains('orphaned');
if (state == "playing") {
player.pauseVideo();
setTimeout(function () {
player.playVideo();
}, 100);
}
if (state == "playing" || orphaned) {
e.returnValue = "Navigating will close the floating video(s).\nDo you want to continue?";
return e.returnValue;
}
}
var youtube_id = i.getAttribute('data-youtube-id');
var parameters = i.getAttribute('data-parameters');
var height = i.getAttribute('data-height');
var width = i.getAttribute('data-width');
parameters = JSON.parse(`{${parameters.replace(/&/g, ',').replace(/(\w+)=/g, '"$1":')}}`);
if (!('iv_load_policy' in parameters)) parameters.iv_load_policy = 3; // annotations off by default
if (!('rel' in parameters)) parameters.rel = 0; // related videos (after video ends) off by default
var elem_id = youtube_id + '_' + (new Date).getTime();
var d = document.createElement('div');
d.id = elem_id + '_placeholder';
d.style.height = height + 'px';
d.style.width = width + 'px';
i.parentElement.replaceChild(d, i);
new MutationObserver(function () {
if (document.getElementById('content').contains(d)) {
var r = d.getBoundingClientRect();
p.style.top = r.top + document.documentElement.scrollTop + 'px';
} else {
this.disconnect();
var s = player.getPlayerState();
if ((s == YT.PlayerState.PLAYING || s == YT.PlayerState.BUFFERING) && !player.isMuted()) {
p.classList.add('orphaned');
t_a.classList.remove('hidden');
t_b.classList.remove('hidden');
} else {
p.remove();
window.removeEventListener('beforeunload', f);
}
}
}).observe(document.getElementById('content'), {childList: true, subtree: true});
var p = document.createElement('div'), r = d.getBoundingClientRect();
var t_a = document.createElement('div'), t_b = document.createElement('div');
p.classList.add('floating');
p.style.top = r.top + document.documentElement.scrollTop + 'px';
p.style.left = r.left + document.documentElement.scrollLeft + 'px';
p.addEventListener('click', e => {
// clicks on the embed are captured by it, not the iframe
// only the 'X' button :after pseudo-element should fire this event
if (e.target.classList.contains('orphaned')) {
window.removeEventListener('beforeunload', f);
e.target.style.opacity = '0';
e.target.addEventListener('transitionend', e => {
e.target.parentNode.removeChild(e.target);
});
}
});
t_a.classList.add('floating-controls-above', 'hidden');
var postHref = document.createElement('a');
postHref.href = post.querySelector('.permalink').href;
postHref.appendChild(document.createTextNode('#' + post.getAttribute('data-pid')));
t_a.appendChild(postHref);
t_b.classList.add('floating-controls-below', 'hidden');
t_b.setAttribute('data-tid', ajaxify.data.tid);
t_b.setAttribute('data-pid', post.getAttribute('data-pid'));
var postUpvote = document.createElement('a');
postUpvote.appendChild(document.createElement('i'));
postUpvote.href = '#';
var postDownvote = postUpvote.cloneNode(true);
postUpvote.firstChild.classList.add('fa', 'fa-chevron-up');
postDownvote.firstChild.classList.add('fa', 'fa-chevron-down');
t_b.appendChild(postUpvote);
t_b.appendChild(postDownvote);
if (post.querySelector('.upvoted[component="post/upvote"]')) {
postUpvote.classList.add('upvoted');
} else if (post.querySelector('.downvoted[component="post/downvote"]')) {
postDownvote.classList.add('downvoted');
}
postUpvote.addEventListener('click', () => {
var dir = postUpvote.classList.contains('upvoted') ? 'un' : 'up';
vote(dir, t_b.getAttribute('data-pid'), t_b.getAttribute('data-tid')).then(e => {
if (e) console.error(e);
else {
if (dir == 'up') postUpvote.classList.add('upvoted');
else postUpvote.classList.remove('upvoted');
postDownvote.classList.remove('downvoted');
}
});
});
postDownvote.addEventListener('click', () => {
var dir = postDownvote.classList.contains('downvoted') ? 'un' : 'down';
vote(dir, t_b.getAttribute('data-pid'), t_b.getAttribute('data-tid')).then(e => {
if (e) console.error(e);
else {
if (dir == 'down') postDownvote.classList.add('downvoted');
else postDownvote.classList.remove('downvoted');
postUpvote.classList.remove('upvoted');
}
});
});
var e = document.createElement('div');
e.id = elem_id;
e.style.height = height + 'px';
e.style.width = width + 'px';
p.appendChild(t_a);
p.appendChild(e);
p.appendChild(t_b);
var c = document.getElementById('content');
c.parentElement.insertBefore(p, c);
var player = new YT.Player(elem_id, {
height: height,
width: width,
videoId: youtube_id,
playerVars: parameters,
events: {
onReady: function onPlayerReady(e) {
try {
e.target.playVideo();
} catch (e) {
console.error(e);
}
},
onStateChange: function onPlayerStateChange(e) {
var n = Object.keys(YT.PlayerState).filter(t => YT.PlayerState[t] == e.data)[0];
p.setAttribute('data-state-name', n.toLowerCase());
}
}
});
window.addEventListener('beforeunload', f);
} catch (e) {
console.log(e);
}
});
});
}).observe(document.getElementById('content'), {childList: true, subtree: true});
}}`);
styleSheet.innerHTML += `
/* YouTube embed floating styles */
.floating {
position: absolute;
line-height: 0;
z-index: 1;
}
.floating.orphaned {
position: fixed;
transition: bottom 1s, opacity 0.2s;
box-shadow: 0 0 15px 15px white;
left: unset !important;
top: unset !important;
bottom: 15px;
right: 15px;
opacity: 1;
}
.floating.orphaned[data-state-name="playing"] {
z-index: 2;
}
.floating.orphaned:after {
content: 'X';
position: absolute;
background: rgba(255, 255, 255, 0.5);
box-shadow: 0 0 5px white;
border-radius: 0.75em;
text-align: center;
line-height: 1.5em;
font-weight: bold;
cursor: pointer;
height: 1.5em;
width: 1.5em;
right: -4px;
top: -10px;
opacity: 0;
transition: 0.2s;
}
.floating.orphaned:hover {
z-index: 3;
}
.floating.orphaned:hover:after {
opacity: 1;
top: -2px;
}
.floating.orphaned iframe {
width: 240px !important;
height: unset !important;
}
.floating-controls-above.hidden, .floating-controls-below.hidden {
display: none;
}
.floating-controls-above, .floating-controls-below {
position: absolute;
width: 100%;
transition: 0.2s;
}
.floating-controls-above a, .floating-controls-below a {
color: grey;
outline: none;
text-decoration: none;
font-size: small;
border-radius: 0.5em;
background: rgba(255,255,255,.2);
box-shadow: 0 0 3px 3px rgba(255,255,255,.2);
transition: 0.2s;
opacity: 0;
}
.floating-controls-above {
top: -4px;
}
.floating-controls-below {
margin-top: -3px;
text-align: right;
}
.floating.orphaned:hover .floating-controls-above {
top: 0px;
}
.floating.orphaned:hover .floating-controls-below {
margin-top: -7px;
}
.floating-controls-below a {
margin-right: 6px;
}
.floating-controls-below a {
height: 1em;
width: 1.1em;
text-align: center;
display: inline-block;
}
.floating-controls-below a i.fa {
position: relative;
top: -1.5px;
}
.floating-controls-below a i.fa.fa-chevron-down {
top: -1px;
}
.floating-controls-below a.upvoted,
.floating-controls-below a.upvoted:hover {
opacity: 0.8;
background: #0b0;
box-shadow: none;
color: #ccc;
}
.floating-controls-below a.downvoted,
.floating-controls-below a.downvoted:hover {
opacity: 0.8;
background: #c00;
box-shadow: none;
color: #ccc;
}
.floating.orphaned:hover .floating-controls-above a,
.floating.orphaned:hover .floating-controls-below a {
opacity: 1;
}
.floating-controls-above a:hover,
.floating-controls-below a:hover {
color: #444;
background: rgba(255,255,255,.5);
box-shadow: 0 0 3px 3px rgba(255,255,255,.5);
}
.floating.orphaned ~ .orphaned {
bottom: 55px !important;
}
.floating.orphaned ~ .orphaned ~ .orphaned {
bottom: 95px !important;
}
.floating.orphaned ~ .orphaned ~ .orphaned ~ .orphaned {
bottom: 135px !important;
}
.floating.orphaned ~ .orphaned ~ .orphaned ~ .orphaned ~ .orphaned {
bottom: 175px !important;
}
/* 5 videos should be enough for anyone, right? */
`;
// for threads which reference an article, load it and inline it in Paula Bean's brillant post
var $paula = "brillant";
//*** fixme ***
// these styles *should* not cause (much) jellypotato...
styleSheet.innerHTML += `
/* Make the "drag and drop images here" div *correctly* cover the composer area */
/* This has kind of sort of been fixed already, but I still like mine better... */
.imagedrop > * {
position: relative;
top: 50%;
transform: translatey(-50%);
}
.imagedrop {
top: 0 !important;
height: 100% !important;
left: 0;
}
/* Replace hyphens with spaces in @mentions (this is an UGLY HACK but goddamn I love it so much) */
a.plugin-mentions-a {
font-family: "@mention";
font-size: inherit !important;
}
@font-face {
font-family: "@mention";
font-style: normal;
font-weight: 400;
src: local("Roboto"), local("Roboto-Regular"), url("https://fonts.gstatic.com/s/roboto/v16/CWB0XYA8bzo0kSThX0UTuA.woff2")format("woff2");
unicode-range: U+0-2C, U+2E-FF, U+131, U+152-153, U+2C6, U+2DA, U+2DC, U+2000-206F, U+2074, U+20AC, U+2212, U+2215, U+E0FF, U+EFFD, U+F000;
}
@font-face {
font-family: "@mention";
font-style: normal;
font-weight: 400;
unicode-range: U+2D;
/* YES REALLY!! */
src: url("data:font/woff2;base64,d09GMgABAAAAAAIwAA0AAAAABaAAAAHdAAEAAAAAAAAAAAAAAAAAAAAAAAAAAAAAP0ZGVE0cGh4GVgCCShEIClRiCwoAATYCJAMQBCAFg0IHKhu5BMgeg+NuCcr488kUotRfL8uOh//2+/0+M3MF06QeOp7xkN4iRkKHRGR1otiXFH7Ta/X76b4g+cw8GbofZuMAyaWEtkaQ8K1xlSirqlsLof93E+kDHeKJrMak8MCO8F5A06R0DZAtEnieUKoJp25bSGdgMIiWg1zxUQyC7eAsBHzWjw3wtfTzW5YODGIaJCHLQkFCpNe4V2krr/SBmMPl8k+tZAISAAQAnN7kG0ghVOM/j9ULCEBGxgwGjogFZkhsgFoREna7Djxf9wMDiBoCEAkkePkcyHhGdJ0ECmhRYRTMAiigAkrGSzTq9vrNvtNT9Av6HuvrLG+kD0/iZgHSxnjIr36tP4pZ8TyKYoBA8Hq39JT1sT8FBexRvx7kDekLWgkEtXzM1ygRYPgnJV/rAeme+INAMUoKEkJgDwCDbgUItWEBksYqBrItRwIUw54xUBnwS4DacJghFkWJMEiBZNgUZJcIihlPUJn23qc249+5sZqxhTEEGlku0dS2NFA1g29hKHBibGRx50xbE+chcOEiuJGCBuhqFy834kU8XNwCEH4kqWDWbnKP4OIRvoBPE2kjvW7y0pUMD3MFuvKAa00zc13HgnO79PHeZOkWXGZBQmxX6xGFw3dGAQA=") format("woff2");
}
/* Prevent @mentions from showing up inside code blocks because ... seriously, wtf? */
/* This has been fixed... but who knows, it might regress someday. At least I won't have to rewrite this if it does. */
/*.hljs a.plugin-mentions-a, code a.plugin-mentions-a {
font: inherit;
pointer-events: none;
}
.hljs, code {
cursor: text;
}*/
/* Make the preview container override the -moz-user-select: none that the entire composer has */
.preview-container .preview {
-moz-user-select: element;
}
/* Force the toaster notifications to appear at the top of the page, not the bottom */
div.alert-window {
top: 70px;
bottom: auto;
}
/* Kill the ugly glowy border on the Lounge */
body {
box-shadow: none !important;
}
/* Make the highlight on unread notifications more visible */
.container li.unread[data-nid] {
background-color: #fcf8af;
}
/* Fix search result panels, because they have a max-height of 250px and cutting them off is :WTF: */
/* Oh, and the stupid "fade-out" element doesn't use a gradient that displays properly in Firefox */
.search-result-text {
max-height: 600px !important;
overflow: auto !important;
}
.search-result-text * {
font-size: 100% !important;
}
.search-result-text .fade-out {
display: none;
}
/* Prevent the edit/delete chat message tools from affecting the message's layout */
.chat-content li.chat-message .controls {
position: relative;
margin-top: -25px;
}
/* Offset the post anchors enough vertically that the top of the post isn't underneath the header */
.topic a[component="post/anchor"] {
top: -83px;
}
/* Give these topics the "read" style -- I'm ignoring them! */
.topic-list li.unread[data-cid="28"] a, /* Games */
.topic-list li.unread[data-cid="29"] a, /* Mafia */
.topic-list li.unread[data-cid="31"] a, /* Mafia - Current Game */
.topic-list li.unread[data-cid="42"] a, /* Borderlands RPG */
.topic-list li.unread[data-cid="43"] a, /* Borderlands RPG - Rules */
.topic-list li.unread[data-cid="44"] a, /* Borderlands RPG - Character Info */
.topic-list li.unread[data-cid="48"] a /* PBP RPGs */ {
color: #555;
}
[component$="/header"] > .fa-arrow-circle-right[title="moved"i] {
display: none;
}
.topic .posts .icon .user-icon {
overflow: hidden;
}
.topic .posts .content .iframely-link:last-child {
margin-bottom: 2px;
}
.content [title]:not([component]):empty:after {
content: attr(title);
font-weight: initial;
font-style: italic;
color: #007c00;
}
.composer .resizer {
left: 0;
}
.composer .resizer .trigger {
width: 100%;
height: 20px;
top: -24px;
left: 0;
margin: 0;
padding-left: 20px;
background: none !important;
border: none !important;
border-radius: 0;
line-height: 26px;
cursor: ns-resize;
text-align: left;
}
.composer .resizer .trigger i {
color: #333;
background: rgba(255,255,255,.5);
border-radius: 50%;
height: 22px;
width: 22px;
text-align: center;
}
.composer .resizer:hover .trigger i {
visibility: hidden;
}
/* this is too easy to click by mistake */
.header .notif-dropdown-link .mark-all-read, .header .notif-dropdown-link:empty {
display: none;
}
.replies-last {
display: none;
}
`;
// these styles *might* cause (more significant) jellypotato, so delay their loading, and try to compensate...
setTimeout(function () {
try {
var t = document.getElementById('header-menu').getBoundingClientRect().bottom,
p = [...document.querySelectorAll('li[component="post"]')], r;
p = document.querySelector('.highlight') || p.filter((e, i) => !i || e.getBoundingClientRect().top < t).pop();
if (p) r = p.getBoundingClientRect();
styleSheet.innerHTML += `
/* Make elements with the "hidden" attribute visible, styled similar to HTML comments */
[hidden] {
display: unset !important;
font-style: italic;
color: #008000;
}
[hidden] * {
font-style: italic;
color: #008000 !important;
}
[hidden] > em {
font-style: initial;
}
/* Ensure that the last element in posts has a margin under it, because otherwise it can overlap the post tools (:wtf:) */
.content > *:last-child {
margin: 0 0 10px;
}
/* Style posts differently based on upvoted or downvoted status... ok, mostly just downvoted */
.topic .posts .downvoted > .content img:not(.emoji):not(:hover), .downvoted > .content video:not(:hover) {
max-height: 20px !important;
max-width: 40px !important;
border: none;
padding: 0;
position: unset !important;
}
.downvoted > .content .js-lazyYT:not(:hover) {
padding-bottom: 90px !important;
width: 160px !important;
height: 90px !important;
}
.downvoted > .content .js-lazyYT:not(:hover) .ytp-large-play-button {
transform: scale(0.4) !important;
}
.downvoted > .content .panel-iframely {
margin: 0;
border: none;
box-shadow: none;
}
.downvoted > .content .iframely-container * {
display: inline !important;
padding: 0;
margin: 0;
width: auto !important;
height: auto !important;
}
.downvoted > .content .panel-iframely h4 {
font-size: inherit;
font-weight: inherit;
line-height: inherit;
}
.downvoted > .content .iframely-meta, .downvoted > .content .iframely-embed .media,
.downvoted > .content .description, .downvoted > .content .thumb {
display: none !important;
}
.downvoted > .content big {
font-size: 100%;
}
/* Replace deleted posts with "This post is deleted !" until you hover over it (if it's a post you deleted) */
/*
.posts .deleted:not(.cached) > .content:not(:hover) {
color: transparent;
}
.posts .deleted:not(.cached) > .content:not(:hover):not(:empty)::before {
content: 'This post is deleted!';
position: absolute;
color: #333;
}
.posts .deleted:not(.cached) > .content:not(:hover) > *,
.posts .deleted:not(.cached) .divider:last-child {
display: none;
}
*/
.deleted[data-uid="${unsafeWindow.app.user.uid}"] > .post-footer > .pull-right > .post-tools [component],
.deleted[data-uid="${unsafeWindow.app.user.uid}"] > .post-footer > .pull-right > .votes,
.deleted[data-uid="${unsafeWindow.app.user.uid}"] > .post-footer > .pull-right > .moderator-tools,
.deleted > .post-footer > .pull-right > .post-tools [component],
.deleted > .post-footer > .pull-right > .moderator-tools,
.deleted > .post-footer > .pull-right > .votes {
display: inline !important;
}
.deleted[data-uid="${unsafeWindow.app.user.uid}"] > .post-footer > .pull-right [component="post/quote"],
.deleted[data-uid="${unsafeWindow.app.user.uid}"] > .post-footer > .pull-right > .votes .fa,
.deleted.not-cached:not([data-uid="${unsafeWindow.app.user.uid}"]) > .post-footer > .pull-right [component="post/reply"],
.deleted > .post-footer > .pull-right [component="post/quote"],
.deleted > .post-footer > .pull-right > .votes .fa {
background: transparent !important;
box-shadow: 0 0 1px 1px lightgray inset;
}
.deleted > .post-footer > .pull-right .divider,
.deleted > .post-footer > .pull-right .view-raw,
.deleted > .post-footer > .pull-right .reply-as-topic {
display: none;
}
.deleted[data-uid="${unsafeWindow.app.user.uid}"] > .post-footer > .pull-right :not([role="presentation"]) > a:not([component="post/reply"]):not([data-toggle]) {
color: lightgray;
}
.deleted.not-cached:not([data-uid="${unsafeWindow.app.user.uid}"]) > .post-footer > .pull-right [component="post/reply"],
.deleted:not([data-uid="${unsafeWindow.app.user.uid}"]) > .post-footer > .pull-right a:not([component="post/reply"]),
.deleted[data-uid="${unsafeWindow.app.user.uid}"] > .post-footer > .pull-right .post-tools a:not([component="post/reply"]),
.deleted[data-uid="${unsafeWindow.app.user.uid}"] > .post-footer > .pull-right .votes a {
pointer-events: none;
color: lightgray;
}
`;
// fix any jellypotato caused by the extra stylesheet... I dunno why it'd cause any, but...
if (p) setTimeout(function () { if ((r.top - p.getBoundingClientRect().top)) {
window.scrollBy(0, r.top - p.getBoundingClientRect().top);
} }, 0);
} catch (e) {
console.error(e);
}
}, 3000);
} else if (document.querySelector('ul.comments')) {
document.querySelector('ul.comments').style.display = 'none';
styleSheet.innerHTML += `
li.comment.dupe *, li.comment.spam:not(:focus) div[itemprop="text"] {
display: none;
}
li.comment.spam:not(.dupe):not(:focus):after {
color: red;
content: 'Comment hidden. Click to view it.';
display: block;
font-style: italic;
margin-top: 3pt;
}
li.comment.spam:not(.dupe):not(:focus) {
cursor: pointer;
}
li.comment.spam:not(.dupe):focus:before {
color: red;
content: 'This comment appears to be spam. Click outside the dotted outline to hide it again.';
cursor: default;
display: block;
font-style: italic;
margin-bottom: 15px;
margin-top: -10px;
}
li.comment.dupe:after {
color: red;
content: 'One or more duplicate comments are hidden.';
cursor: default;
display: block;
font-style: italic;
margin-top: 3pt;
}
li.comment.dupe:-moz-focusring { /* FF requires this to be inside the :-moz-focusring pseudo-selector to work... */
outline: none;
}
li.comment.dupe + li.comment.dupe {
display: none;
}
`;
var c = [], comments = [...document.querySelectorAll('li.comment')];
comments.filter(comment => !comment.querySelector(`[itemprop="text"] .comment-moderation`)).forEach(comment => {
var text = getText(comment.querySelector('div[itemprop="text"]')).replace(/\s+/, ' ').trim();
var normalized_text = normalize_text(text).replace(/\s+/, ' ').trim();
var lzw = lzw_encode(normalized_text), compression = lzw.length / Math.pow(text.length, .83);
comment.title = `Post compression score: ${+compression.toFixed(5)}`;
var i = c.indexOf(text);
if (i < 0) c.push(text);
var limit = 1.5; // comment.querySelector('.poster-anon') ? 1.5 : 1.3;
if (i >= 0 || (text.length > 255 && compression < limit)) {
if (text == c[i]) comment.classList.add('dupe');
comment.classList.add('spam');
comment.setAttribute('tabindex', '-1'); // hack to allow :focus CSS selectors to target element when clicked
}
});
document.querySelector('ul.comments').style.display = null;
} else { // not NodeBB
styleSheet.innerHTML += "img.inset{width:auto;max-width:30%}";
}
} catch (e) {
console.error(e);
}
})();
} catch (e) {
console.error(e);
}
// like e.innerText, but includes the src attributes of all images in the captured text
function getText(e) {
if (e.nodeName == "IMG") {
return ' ' + e.src;
} else if (e.nodeType == e.TEXT_NODE) {
return e.data;
} else if (e.childNodes.length) {
return [... e.childNodes].map(getText).join('');
} else {
return e.innerText || '';
}
}
// removes Zalgo and converts text to the ISO basic Latin charset
function normalize_text(t) {
var a = [...Array(95)].map((_, i) => String.fromCharCode(i + 32)).sort((a, b) => a.localeCompare(b));
return t.normalize('NFKD')
.replace(/[\u0300-\u036F]/g, '').split(/(?![\udc00-\udfff])/)
.map(c => a.indexOf(c) >= 0 || /\s/.test(c) ? c
: (a[a.map(a => c.localeCompare(a)).lastIndexOf(1)] || '')[c == c.toUpperCase() ? 'toUpperCase' : 'toLowerCase']())
.join('');
}
// very simple lzw encode function in javascript
// based on revolunet/lzw_encoder.js on github (https://gist.github.com/revolunet/843889)
function lzw_encode(s) {
var data = unescape(encodeURIComponent(s)).split(""), phrase = data[0];
for (var dict = {}, code = 256, out = [], c, i = 1; i < data.length; ++ i) {
c = data[i];
if (dict[phrase + c] != null) phrase += c;
else {
out.push(phrase.length > 1 ? dict[phrase] : phrase.charCodeAt(0));
dict[phrase + c] = code ++;
phrase = c;
}
}
if (phrase) out.push(phrase.length > 1 ? dict[phrase] : phrase.charCodeAt(0));
return out.map(c => String.fromCharCode(c)).join("");
}