// ==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(""); }