// ==UserScript==
// @name Empornium ThreadMan
// @description Thread visibility management
// @namespace Empornium Scripts
// @version 1.4.1
// @author vandenium
// @grant GM_setValue
// @grant GM_getValue
// @grant GM_addStyle
// @include /^https:\/\/(www\.)?empornium\.(me|sx|is)\/$/
// @include /^https:\/\/(www\.)?empornium\.(me|sx|is)\/(forum|torrents|articles|userhistory|index)+/
// ==/UserScript==
// Changelog:
// Version 1.4.1
// - Preserve link's <strong> HTML tag
// Version 1.4.0
// - Added a blacklist CSS animation
// Version 1.3.1
// - Instead of changing link color, add green heart next to thread.
// Version 1.3.0
// - Add Greenlist hotkey, "g".
// - Sets color of links to green.
// Version 1.2.1
// - Move settings code to end of code
// - Move processThreads code higher.
// Version 1.2.0
// - Adds Greenlist
// - Threads in the Greenlist are always shown regardless of viewed status.
// - Threads created by users (after updating to 1.2.0) are always added to the Greenlist.
// - The Greenlist is not retroactive. Threads created by users prior to using version
// 1.2.0 need to be manually added to the Greenlist.
// - Fix eventing issue causing repeated event listener executions.
// Version 1.1.1
// - Ignore keyup event when cursor in new post area.
// Version 1.1.0
// - Added post forward/back navigation hotkeys in threads.
// - Forward: d, Backward: e
// Version 1.0.10
// - Fix textarea background color for all themes
// Version 1.0.9
// - Fix h1,h3 style
// Version 1.0.8
// - Fix Settings dimensions
// Version 1.0.7
// - Update Settings colors to be consistent in all themes.
// Version 1.0.6
// - Fix break in 1.0.5 to include the homepage (with/without index.php)
// Version 1.0.5
// - Fix @include, was trying to run on pages with no Latest Forum Threads section.
// Version 1.0.4
// - Update @include to one-liner.
// Version 1.0.3
// - Adding index to includes.
// Version 1.0.2
// - Move settings link to the furthest left of user dropdown area.
// Version 1.0.1
// - Update hotkey thread highlighting
// Version 1.0.0
// - The initial version:
// - Features:
// - Whitelist/blacklist/Mark Read
// - Works on Latest Forum Threads and Forum pages.
// - Hide thread based on whether you've clicked since most recent.
// - Hotkeys: blacklist (b), whitelist (w), mark read (r)
// - Settings dialog to set/clear above.
// Todo:
// - Status area (how many threads currently hidden, etc.)
GM_addStyle(`
@keyframes greenlist {
0% {
background-color: #90b180;
}
100% {
background-color: initial;
}
}
.greenlist-animation {
animation: greenlist 1s;
}
.spin-out {
animation-name: spinOut;
animation-duration: 1.5s;
animation-fill-mode: both;
}
@keyframes spinOut {
0% {
opacity: 1;
transform-origin: 50% 50%;
transform: scale(1, 1) rotateY(0deg);
}
100% {
opacity: 0;
transform-origin: 50% 50%;
transform: scale(0, 0) rotateY(360deg);
}
}
`);
const initialOptions = {
options: {
whitelist: {
threads: new Set(),
},
blacklist: {
threads: new Set(),
},
greenlist: {
threads: new Set(),
}
},
userSelected: {
threads: new Set(),
},
};
const optionsKey = 'empornium-threadman-options';
const getOptions = () => {
const options = GM_getValue(optionsKey);
if (options) {
const rawOptions = JSON.parse(options);
if (!rawOptions.options.greenlist) {
rawOptions.options.greenlist = {
threads: '[]'
};
}
// convert whitelist/blacklist back to sets
rawOptions.options.whitelist.threads = new Set(JSON.parse(rawOptions.options.whitelist.threads));
rawOptions.options.blacklist.threads = new Set(JSON.parse(rawOptions.options.blacklist.threads));
rawOptions.options.greenlist.threads = new Set(JSON.parse(rawOptions.options.greenlist.threads));
rawOptions.userSelected.threads = new Set(JSON.parse(rawOptions.userSelected.threads));
// console.log('Options from GM: ', JSON.stringify(rawOptions, null, 4))
return rawOptions;
}
return initialOptions;
};
const setOptions = (options = initialOptions) => {
// console.log(`Setting options to ${optionsKey}:`, options);
// convert sets to arrays
options.options.whitelist.threads = JSON.stringify([...options.options.whitelist.threads]);
options.options.blacklist.threads = JSON.stringify([...options.options.blacklist.threads]);
options.options.greenlist.threads = JSON.stringify([...options.options.greenlist.threads]);
options.userSelected.threads = JSON.stringify([...options.userSelected.threads]);
GM_setValue(optionsKey, JSON.stringify(options ? options : initialOptions));
};
const getLatestForumThreads = () => Array.from(document.querySelectorAll('.latest_threads > span'));
const getForumThreads = () => Array.from(document.querySelectorAll('table.forum_list tr.rowa, table.forum_list tr.rowb'));
const getThreadMetaData = (thread) => {
if (thread.children[0].nodeName.toLowerCase() === 'span') { // Latest forum thread
const link = thread.children[1].href;
return {
id: link.split('/thread/')[1].split('?')[0],
name: thread.children[1].textContent.trim(),
timestamp: new Date(thread.children[3].title),
};
} else { // forum
const firstAnchor = thread.querySelector('a');
return {
name: firstAnchor.textContent,
id: firstAnchor.href.split('/thread/')[1],
timestamp: thread.querySelector('span.time').title,
};
}
};
// Shows/hides threads based on options and displayed threads.
// - Latest Forums section (top of multiple pages)
// - Forum Page
const processThreads = (threads) => {
const options = getOptions();
threads.forEach(thread => {
const threadMetaData = getThreadMetaData(thread);
// Handle whitelist threads
if (options.options.whitelist.threads.size > 0) {
if (options.options.whitelist.threads.has(threadMetaData.id)) {
thread.hidden = false;
} else {
thread.hidden = true;
}
} else {
// Handle blacklisted threads
if (options.options.blacklist.threads.has(threadMetaData.id)) {
thread.hidden = true;
}
}
// Handle greenlist threads (these are always shown - they trump previous visibility)
if (options.options.greenlist.threads.size > 0) {
if (options.options.greenlist.threads.has(threadMetaData.id)) {
const link = thread.querySelector('a');
link.innerHTML = `<strong>💚${link.textContent}</strong>`;
thread.hidden = false;
}
}
// Handle clicked threads. Iterate over userselected threads. if any match current thread, check date and optionally hide.
options.userSelected.threads.forEach(selectedThread => {
if (selectedThread.id === threadMetaData.id) {
const threadDate = new Date(threadMetaData.timestamp);
const clickedDate = new Date(selectedThread.lastClicked);
// check date and if greenlisted. always show greenlisted thread.
if (clickedDate > threadDate && !options.options.greenlist.threads.has(threadMetaData.id)) {
console.log(`Hiding already viewed thread, ${threadMetaData.name}`);
thread.hidden = true;
}
}
});
});
};
//-----------Settings Dialog----------------
const template = `
<style>
#threadman-options-outer-container {
position: absolute;
left: 50%;
transform: translate(-50%, -50%);
z-index: 100;
top: 50%;
width: 1000px;
height: 430px;
border: solid #333 1px;
background-color: rgb(0,0,0,0.9);
border-radius: 15px;
margin: 5px;
}
#threadman-options-outer-container h1 {
color: #ccc;
}
#threadman-options-outer-container h3 {
color: #ccc;
}
.threadman-options-container {
max-width: 1000px;
width: 100%;
position: relative;
margin: 15px;
}
.threadman-options-container textarea {
background: #333;
color: #ccc;
}
.options-inner {
width: 24%;
margin-right: 5px;
display: inline-block;
}
#threadman-save-settings {
margin-top: 10px;
}
#close-threadman-settings a {
float: right;
margin: 0px 30px;
text-decoration:none;
width: 20px;
height: 20px;
border-radius: 10px;
font-size: 1.3em;
}
#close-threadman-settings a:hover {
background-color: rgba(100,100,10,0.9);
}
</style>
<div class="threadman-options-container" id="threadman-option-container">
<div id='close-threadman-settings'><a href='#'>✖️</a></div>
<h1>Empornium ThreadMan Settings</h1>
<div>
<div class='options-inner'>
<h3>Blacklist</h3>
<textarea id= "blacklist" rows="15" cols="27" placeholder="Hide all threads in this list (comma-separated thread IDs)"></textarea>
</div>
<div class='options-inner'>
<h3>Whitelist</h3>
<textarea id="whitelist" rows="15" cols="27" placeholder="Only show threads in this list (comma-separated thread IDs, Blacklist ignored)"></textarea>
</div>
<div class='options-inner'>
<h3>Greenlist</h3>
<textarea id="greenlist" rows="15" cols="27" placeholder="Always show threads in this list. Forum threads you create will be automatically added to this list. (comma-separated thread IDs)"></textarea>
</div>
<div class='options-inner'>
<h3>Thread Click Log</h3>
<textarea id="userclicks" rows="15" cols="27"></textarea>
</div>
</div>
<div>
<button id='threadman-save-settings'>Save Settings</button>
</div>
</div>
`;
//-----------------------------------------
const hideSettings = () => document.querySelector('#threadman-options-outer-container').remove();
const isNumeric = (num) => !isNaN(num);
const cleanUserSettings = (list) => list.filter(val => val !== '' && isNumeric(val));
const showSettings = () => {
const createTemplateDOM = (str) => {
const template = document.createElement('div');
template.id = 'threadman-options-outer-container';
template.innerHTML = str;
return template;
};
const dom = createTemplateDOM(template);
// Get settings
const options = getOptions();
console.log('options in settings', options);
// Set blacklist settings
dom.querySelector('#blacklist').textContent = [...options.options.blacklist.threads];
// Set whitelist settings
dom.querySelector('#whitelist').textContent = [...options.options.whitelist.threads];
// Set greenlist settings
dom.querySelector('#greenlist').textContent = [...options.options.greenlist.threads];
// Set userclicks
dom.querySelector('#userclicks').textContent = JSON.stringify([...options.userSelected.threads]);
// Save settings
dom.querySelector('#threadman-save-settings').addEventListener('click', () => {
const blacklistSettingsRaw = dom.querySelector('#blacklist').value.trim();
const whitelistSettingsRaw = dom.querySelector('#whitelist').value.trim();
const greenlistSettingsRaw = dom.querySelector('#greenlist').value.trim();
const userSelectedRaw = dom.querySelector('#userclicks').value.trim();
// clean blacklist
const blacklistSettingsListRaw = blacklistSettingsRaw.split(',');
const blacklistSettings = cleanUserSettings(blacklistSettingsListRaw);
// clean whitelist
const whitelistSettingsListRaw = whitelistSettingsRaw.split(',');
const whitelistSettings = cleanUserSettings(whitelistSettingsListRaw);
// clean greenlist
const greenlistSettingsListRaw = greenlistSettingsRaw.split(',');
const greenlistSettings = cleanUserSettings(greenlistSettingsListRaw);
// set options, save, close, refresh.
options.options.blacklist.threads = new Set(blacklistSettings);
options.options.whitelist.threads = new Set(whitelistSettings);
options.options.greenlist.threads = new Set(greenlistSettings);
options.userSelected.threads = userSelectedRaw === '' ? new Set() : new Set(JSON.parse(userSelectedRaw));
setOptions(options);
hideSettings();
window.location.reload();
});
// Close settings
dom.querySelector('#close-threadman-settings a').addEventListener('click', hideSettings);
// Add to document.
const body = document.querySelector('body');
body.appendChild(dom);
};
// On click, need to set the lastClicked property on options.
// Search through all threads, if exists, update, else create new.
const addClickToOptions = (threadMetaData) => {
const options = getOptions();
const userSelectedThreads = [...options.userSelected.threads];
const found = userSelectedThreads.find(thread => thread.id === threadMetaData.id);
if (found) { // delete old value, add new with updated date
options.userSelected.threads.delete(found);
options.userSelected.threads.add({
id: found.id,
lastClicked: new Date(),
});
} else {
options.userSelected.threads.add({
id: threadMetaData.id,
lastClicked: new Date(),
});
}
setOptions(options);
};
/**
* Adds click handlers to threads
* @param {*} threads List of threads
* @param {*} selectorToThread Selector to the top-level element of thread starting from the event target.
*/
const addClickHandlerToThreads = (threads, selectorToThread) => {
threads.forEach(thread => {
thread.addEventListener('click', (el) => {
const threadMetaData = getThreadMetaData(el.target.closest(selectorToThread));
addClickToOptions(threadMetaData);
});
thread.addEventListener('mouseenter', (e) => {
e.target.classList.add('threadman-thread-target');
});
thread.addEventListener('mouseleave', (e) => {
e.target.classList.remove('threadman-thread-target');
});
});
};
// Add settings link to page.
const addSettingsLink = () => {
const ul = document.createElement('ul');
const li = document.createElement('li');
ul.append(li);
ul.style.display = 'inline-block';
const a = document.createElement('a');
a.href = '#';
a.textContent = 'ThreadMan🦸♂️Settings';
a.addEventListener('click', () => {
showSettings();
});
li.appendChild(a);
const container = document.querySelector('#major_stats');
container.prepend(ul);
};
/**
* Main execution
*/
//setOptions(); // For clearing out all settings.
// Get threads
const latestForumThreads = getLatestForumThreads();
const forumThreads = getForumThreads();
// Process Latest Forum threads
processThreads(latestForumThreads);
// Process Forum Pages
processThreads(forumThreads);
// Click handlers
addClickHandlerToThreads(latestForumThreads, '.latest_threads > span');
addClickHandlerToThreads(forumThreads, 'tr');
const markThread = (type, thread) => {
let bgColor;
function addClassBlackList() {
thread.style.display = 'inline-block';
thread.classList.add('spin-out');
setTimeout(() => { thread.style.display = 'none'; thread.remove(); }, 900);
}
if (type === 'blacklist') {
addClassBlackList();
}
if (type === 'whitelist') {
bgColor = 'whitesmoke';
thread.classList.remove('rowa');
thread.classList.remove('rowb');
thread.style.border = 'solid gainsboro 1px';
thread.style.transition = 'opacity 0.75s';
}
if (type === 'greenlist') {
thread.classList.add('greenlist-animation');
}
if (type === 'read') {
bgColor = '#ACE1AF';
thread.classList.remove('rowa');
thread.classList.remove('rowb');
thread.style.transition = 'opacity 1.2s';
thread.style.opacity = 0;
thread.style.border = 'solid gainsboro 1px';
setTimeout(() => {
thread.remove();
}, 700);
}
thread.style.backgroundColor = bgColor;
thread.style.borderRadius = '2px';
};
// Handles hotkey presses for latest forum and forum threads
const hotkeyHandler = (thread, e) => {
const threadMetaData = getThreadMetaData(thread);
if (e.keyCode === 66) { // b blacklist
if (thread.classList.contains('threadman-thread-target')) {
// add thread id to blacklist options
const options = getOptions();
if (!options.options.greenlist.threads.has(threadMetaData.id)) {
options.options.blacklist.threads.add(threadMetaData.id);
setOptions(options);
markThread('blacklist', thread);
}
}
}
if (e.keyCode === 71) { // g greenlist
if (thread.classList.contains('threadman-thread-target')) {
// add thread id to greenlist options
const options = getOptions();
if (!options.options.greenlist.threads.has(threadMetaData.id)) {
options.options.greenlist.threads.add(threadMetaData.id);
const link = thread.querySelector('a');
link.textContent = `💚${link.textContent}`;
setOptions(options);
markThread('greenlist', thread);
}
}
}
if (e.keyCode === 87) { // w whitelist
if (thread.classList.contains('threadman-thread-target')) {
const options = getOptions();
if (!options.options.whitelist.threads.has(threadMetaData.id)) {
options.options.whitelist.threads.add(threadMetaData.id);
}
setOptions(options);
markThread('whitelist', thread);
}
}
if (e.keyCode === 82) { // r read
if (thread.classList.contains('threadman-thread-target')) {
addClickToOptions(threadMetaData);
markThread('read', thread);
}
}
};
// For post stepping hotkeys.
if (window.location.pathname.includes('forum/thread')) {
const posts = document.querySelectorAll('table.forum_post[id*=post]');
let n = window.scrollY > 0 ? posts.length - 1 : 0;
const highlightStyleSet = 'box-shadow: 0px 0px 5px 4px; filter: hue-rotate(25deg)';
const highlightStyleSetUnset = 'box-shadow: none; filter:none';
posts[n].children[0].style.cssText = highlightStyleSet;
document.addEventListener('keyup', (e) => {
if (e.target.nodeName === 'INPUT' || e.target.nodeName === 'TEXTAREA') return;
if (e.key === 'd') {
if (n >= (posts.length - 1)) {
n = posts.length - 1;
} else {
posts[n].children[0].style = highlightStyleSetUnset;
const nextEl = posts[n].nextElementSibling;
nextEl.children[0].style.cssText = highlightStyleSet;
nextEl.scrollIntoView({ behavior: 'smooth' });
n += 1;
}
}
if (e.key === 'e') {
if (n <= 0) {
window.scrollTo(0, 0);
n = 0;
} else {
posts[n].children[0].style = highlightStyleSetUnset;
const prevEl = posts[n].previousElementSibling;
prevEl.children[0].style = highlightStyleSet;
prevEl.scrollIntoView({ behavior: 'smooth' });
n -= 1;
}
}
});
}
// Greenlist-related code.
const isThreadPage1 = () => {
return /forum\/thread\/[0-9]+(\?page=1)?$/.test(document.location.pathname) &&
(document.location.search === '' || document.location.search.includes('page=1'));
};
const getPostAt = (n) => document.querySelectorAll('table.forum_post')[n];
const getPostOwnerId = (postDom) => postDom.querySelector('.user_name a').href.split('=')[1];
const getCurrentUserId = () => document.querySelector('#nav_userinfo a').href.split('=')[1];
const isModeratorMovedPost = () => {
const firstPost = getPostAt(0);
const firstPostUserText = firstPost.querySelector('#user_dropdown'); // not authenticated user's
if (firstPostUserText) {
const postUserText = firstPostUserText.innerText.toLowerCase();
return postUserText.includes('moderator') || postUserText.includes('admin');
}
return false;
};
const isThreadStartedByMe = () => {
const userId = getCurrentUserId();
if (isModeratorMovedPost()) {
return userId === getPostOwnerId(getPostAt(1));
}
return (userId === getPostOwnerId(getPostAt(0)));
};
// After creating a new thread, the user will hit this conditional.
// This id should be greenlisted.
if (isThreadPage1() && isThreadStartedByMe()) {
const threadId = document.location.pathname.split('/')[3];
const options = getOptions();
options.options.greenlist.threads.add(threadId);
setOptions(options);
}
// Hotkeys
document.querySelector('body').addEventListener('keydown', (e) => {
// Escape closes options dialog
if (e.key === 'Escape') {
hideSettings();
}
// Setup hotkey functionality for latest forum and forum threads.
getLatestForumThreads().forEach((thread) => hotkeyHandler(thread, e));
getForumThreads().forEach((thread) => hotkeyHandler(thread, e));
});
// Settings link
addSettingsLink();