NOTICE: By continued use of this site you understand and agree to the binding Terms of Service and Privacy Policy.
// JavaScript source code // ==UserScript== // @name TCF Web Tweaks // @namespace com.minimeh.www // @version 0.9.1.3 // @description Tivo Community Forum usability tweaks // @author minimeh // @copyright 2018, minimeh (https://openuserjs.org/users/minimeh) // @license GPL-3.0-or-later; http://www.gnu.org/licenses/gpl-3.0.txt // @supportURL mailto://minimeh@outlook.com // @run-at document-idle // @match http*://www.tivocommunity.com/community/index.php* // @require https://greasemonkey.github.io/gm4-polyfill/gm4-polyfill.js // @grant GM.getValue // @grant GM_getValue // @grant GM.setValue // @grant GM_setValue // ==/UserScript== /* eslint-disable no-multi-spaces*/ /* jshint ignore:start */ // JSHint doesn't recognize async/await at this time. Trying // to work around it with more targeted start/end blocks // is too messy. // --- Configuration Settings --- // // ------ Hotkeys // To disable a hotkey, assign it 0, // e.g. var tcfMarkRead = 0; var tcfMarkRead = "m"; // Perform a mark read operation var tcfNavigatePrev = ","; // Navigate to previous page when available var tcfNavigatePrevAlt = "<"; // Synonym for navigate previous page var tcfNavigateNext = "."; // Navigate to next page when available var tcfNavigateNextAlt = ">"; // Synonym for navigate next page var tcfWatchedThreads = "t"; // Jump to watched threads page var tcfWatchedForums = "f"; // Jump to watched forums page var tcfAllForums = "a"; // Jump to all forums page var tcfNewPosts = "n"; // Jump to new posts page var tcfSpy = "s"; // Jump to spy page var tcfHomeForum = "h"; // Jump to page 1 of parent of current page var tcfUpForum = "u"; // Jump to page that launched current page var tcfGoToFirstUnread = "g"; // When forums listed: Go to first forum with unread threads // When message threads listed: Open first unread message thread // When message thread opened: Go to first unread message // ------ Permissions var tcfInhibitPreviewPopup = true; // ------ Mark forum read settings var tcfAllowMarkRead1 = true; // Allow auto execution of "Mark Group Read" var tcfAllowMarkRead2 = true; // Allow automatically jumping to a destination after marking group read var tcfPostMarkReadDest = "watchedForums"; // Destinations: allForums watchedForums watchedThreads newPosts spy // ------ Editable element types to ignore hotkeys when focused var inputTypes = [ "text", "password", "number", "email", "tel", "url", "search", "date", "datetime", "datetime-local", "time", "month", "week" ]; // ------ TCF forum names var tcfForumNames = [ "Main TiVo Forums", "TiVo TV Talk", "Underground Playground", "Off Topic Areas (Non-TiVo)", "Forum Extras" ]; // --- End Configuration Settings --- // // Utility function for parsing href links function getMenuCommand(text) { 'use strict'; var itemsUl = document.getElementsByClassName("secondaryContent"); if (itemsUl) { for (var itemUl = 0; itemUl < itemsUl.length; ++itemUl) { var itemsLi = itemsUl[itemUl].getElementsByTagName("li"); for (var i = 0; i < itemsLi.length; ++i) { if (itemsLi[i].innerHTML.indexOf(text) >= 0) { return itemsLi[i]; } } } } return null; } // Utility function to stop the timer and reset the timer id. // This is an async command to work with GreaseMonkey's // async GM read/write in synchronous fashion. async function killTimer() { 'use strict'; var intervalID = await GM.getValue("intervalID", -1); if (intervalID != -1) { clearInterval(intervalID); GM.setValue("intervalID", -1); } } // Execute the mark forum read function. // This is an async command to work with GreaseMonkey's // async GM read/write in synchronous fashion. async function markForumRead() { 'use strict'; var liMarkForumsRead = getMenuCommand("Mark Forums Read"); if (liMarkForumsRead) { await GM.setValue("stateMarkForumRead", "0"); var intervalID = window.setInterval(processMarkForumRead, 50); await GM.setValue("intervalID", intervalID); liMarkForumsRead.getElementsByTagName('a')[0].click(); await GM.setValue("stateMarkForumRead", "1"); } } // Execute the next step of marking a forum read. // Called on a timer created in markForumRead() and // on page load event as set up in the document ready // anonymous function. // This is an async command to work with GreaseMonkey's // async GM read/write in synchronous fashion. async function processMarkForumRead() { 'use strict'; var state = await GM.getValue("stateMarkForumRead", "0"); switch (state) { case "0": // Nothing to do here. break; case "1": // Clear the list of parents await GM.setValue("tcf_forum_page", "[]"); // Is automatically marking all forums read allowed or was // this just a simple bring up the mark forums read dialog? if (tcfAllowMarkRead1) { // Cleared to dismiss the mark forums read dialog which // defaults to mark the current forum read. // Find the accept input button var inputs = document.querySelectorAll('input'); var target = null; for (var count = 0; count < inputs.length; ++count) { if (inputs[count].value === "Mark Forums Read") { target = inputs[count]; break; } } // If the target button is up, click it. If not // we'll try again on the timer interval, repeating // until the dialog is ready. if (target) { await GM.setValue("stateMarkForumRead", "2"); target.click(); } } else { // User does not want to automatically dismiss the // mark forums read dialog, so simply cancel the timer // leaving the dialog up for user interaction. killTimer(); await GM.setValue("stateMarkForumRead", "0"); } break; case "2": // AFter marking the forum read, do we want to jump to // another page or just stay in the current forum? if (tcfAllowMarkRead2) { // We want to jump to a specified page. // Wait until the mark forums read dialog has been processed // and closed. Otherwise, we may jump out before the forum is // actually marked as read. // Find the dialog var elements = document.getElementsByClassName("xenOverlay"); if (elements) { var isBlocked = false; // Look for the particular mark forums read dialog, which, // sadly, is identified by having no id. for (var index = 0; index < elements.length; ++index) { var element = elements[index]; if (element.id === "") { // Mark forums read dialog located, now check if it // is still working. If it is, it will have a style // display attribute of "block" as it is blocking // everything else on the page out. if (element.style.display === "block") { isBlocked = true; } break; } } // If still blocked, then exit and try again on the timer interval. if (isBlocked === false) { // Not blocked, so the dialog is complete and we can safely jump. killTimer(); await GM.setValue("stateMarkForumRead", "0"); switch (tcfPostMarkReadDest) { default: case "allForums": allForums(); break; case "watchedForums": watchedForums(); break; case "watchedThreads": watchedThreads(); break; case "newPosts": newPosts(); break; case "Spy": spy(); break; } } } } else { // Further automatic processing of the mark forums read // dialog is not wanted, so kill the timer and reset // the mark forum read state. killTimer(); await GM.setValue("stateMarkForumRead", "0"); } break; default: // Unsure how we got here, but do a reset killTimer(); await GM.setValue("stateMarkForumRead", "0"); break; } } // Move up the parent tree to the page that launched the // current page. // This is an async command to work with GreaseMonkey's // async GM read/write in synchronous fashion. async function upForum() { 'use strict'; var hrefs = await GM.getValue("tcf_forum_page", "[]"); hrefs = JSON.parse(hrefs); if (hrefs && hrefs.length) { var href = hrefs.pop(); var indexPage = href.indexOf("/page"); var hrefRoot = null; if (indexPage >= 0) { hrefRoot = href.substr(0, indexPage); } if (hrefRoot && document.URL.startsWith(hrefRoot)) { href = hrefs.pop(); } window.location.href = href; GM.setValue("tcf_forum_page", JSON.stringify(hrefs)); } } // If the current page is one of the root pages, then // push it onto the stack of parent pages. // This is an async command to work with GreaseMonkey's // async GM read/write in synchronous fashion. async function getCurrentForumPage(event) { 'use strict'; // Get href of the current page var href = document.URL; // If the current page is a recognized parent page, if (href && href.indexOf("index.php?forums") >= 0 || href.indexOf("index.php?spy") >= 0 || href.indexOf("index.php?watched") >= 0 || href.indexOf("index.php?find-new") >= 0 || href.endsWith("index.php")) { // This is a root page. // Grab the list of parent pages pushed onto the stack var hrefs = await GM.getValue("tcf_forum_page", "[]"); if (hrefs) { // The stack has been persisted as a JSON string, // so parse it back into an array. hrefs = JSON.parse(hrefs); } // Ensure that hrefs is not null if (!hrefs) { hrefs = []; } // If there no other entries pushed onto the stack, push this page. if (hrefs.length === 0) { hrefs.push(href); } else { // Locate any "/page" reference in the href var indexPage = href.indexOf("/page"); var hrefRoot = null; // If there is a page reference, extract the root preceding the page reference. if (indexPage >= 0) { hrefRoot = href.substr(0, indexPage); } // If we have a root reference to a page, and the last stack // entry begins with that root reference, then replace the // last stack entry with the root reference. if (hrefRoot && hrefs[hrefs.length - 1].startsWith(hrefRoot)) { hrefs[hrefs.length - 1] = href; } else { // Otherwise, push the href onto the stack hrefs.push(href); } } // Save the stack back into perssisten storage for // reference from another page. The stack is translated // to a JSON string. GM.setValue("tcf_forum_page", JSON.stringify(hrefs)); } } // Jump to the current pages home forum. Goes directly to // to page 1 as opposed to the launching page. function homeForum() { 'use strict'; var crumbs = document.getElementsByClassName("crumb"); for (var i = 0; i < crumbs.length - 3; ++i) { if (crumbs[i].text == "Home" && crumbs[i + 1].text == "Forums" && tcfForumNames.indexOf(crumbs[i + 2].text) >= 0) { window.location.href = crumbs[i + 3].href; break; } } } // Jump to all forums list. Note that any forums that have // been "ignored" will be missing as usual. function allForums() { 'use strict'; var anchors = document.getElementsByTagName('a'); for (var a = 0; a < anchors.length; ++a) { if (anchors[a].text == "Forums") { GM.setValue("tcf_forum_page", "[]"); window.location.href = anchors[a].href; return; } } } // Jump to the watched threads page. function watchedThreads() { 'use strict'; var liWatchedThreads = getMenuCommand("Watched Threads"); if (liWatchedThreads) { liWatchedThreads.getElementsByTagName('a')[0].click(); } } // Jump to the watched forums page. function watchedForums() { 'use strict'; var liWatchedForums = getMenuCommand("Watched Forums"); if (liWatchedForums) { liWatchedForums.getElementsByTagName('a')[0].click(); } } // Jump to the new posts page. function newPosts() { 'use strict'; var liNewPosts = getMenuCommand("New Posts"); if (liNewPosts) { liNewPosts.getElementsByTagName('a')[0].click(); } } // Jump to the spy page. function spy() { 'use strict'; var liSpy = getMenuCommand("Spy"); if (liSpy) { liSpy.getElementsByTagName('a')[0].click(); } } // Utility function to grab the href to a "next" or // "previous" navigation command. function getNavigate(text) { 'use strict'; var navs = document.getElementsByTagName('nav'); if (navs) { for (var i = 0; i < navs.length; ++i) { if (navs[i].innerText.indexOf(text) >= 0) { var items = navs[i].getElementsByTagName('a'); for (var j = 0; j < items.length; ++j) { if (items[j].text == text) { return items[j].href; } } } } } return null; } // Jump to the previous page in a numbered series. function navigatePrev() { 'use strict'; var href = getNavigate("< Prev"); if (href) { window.location.href = href; } } // Jump to the next page in a numbered series. function navigateNext() { 'use strict'; var href = getNavigate("Next >"); if (href) { window.location.href = href; } } // Jump to first: // forum with unread messages from a forum list // thread with unread messages from a thread list // unread message from inside a thread function goToFirstUnread() { 'use strict'; var href = null; // Are we on a forums list page? var unReadForums = document.getElementsByClassName("nodeInfo forumNodeInfo unread"); if (unReadForums && unReadForums.length > 0) { // We are on a forums list page. // Find first un-ignored forum for (var unReadForum of unReadForums) { if (unReadForum.parentElement.className.indexOf("ignored") < 0) { href = unReadForum.getElementsByClassName("nodeTitle")[0].getElementsByTagName("a")[0].href; break; } } } else { // Is there a "Go to first unread" button on the page? var unreadMessages = document.getElementsByClassName("text distinct unreadLink"); if (unreadMessages && unreadMessages.length > 0) { // There is a "Go to first unread" button on the page. Just invoke that. href = unreadMessages[0].href; } else { // No handy button, so do own search for an unread but not // ignored thread or message. unreadMessages = document.getElementsByClassName("unread"); if (unreadMessages && unreadMessages.length > 0) { // Find first un-ignored message thread for (var unreadMessage of unreadMessages) { if (unreadMessage.className.indexOf("ignored") < 0) { var unReadLink = unreadMessage.getElementsByClassName("unreadLink"); href = unReadLink[0].href; break; } } } } } // If we found a reference to something, bring it up. if (href) { window.location.href = href; } } // Checks the focused element of the page to see if it is an // editable element. Hotkeys will not be in effect when the // focused element is editable. function isEditableTextBox(element) { 'use strict'; // the following returns false inappropriately and us unreliable // if(!element.isContentEditable) return false; var tagName = element.tagName.toLowerCase(); if (tagName === 'textarea') return true; if (tagName !== 'input') return false; var type = element.getAttribute('type').toLowerCase(); return inputTypes.indexOf(type) >= 0; } // Evaluate every keypress for being a hotkey. function key_event(event) { 'use strict'; var key = null; if (event.key) { key = event.key; } else { key = event.keyCode; // deprecated, .key is preferred } // Look for hotkeys. // Ensure that the focused element of the page is not editable. if (!(event.ctrlKey || event.altKey || event.metaKey) && !isEditableTextBox(document.activeElement)) { switch (key.toLowerCase()) { case tcfMarkRead: markForumRead(); break; case tcfNavigatePrev: case tcfNavigatePrevAlt: navigatePrev(); break; case tcfNavigateNextAlt: case tcfNavigateNext: navigateNext(); break; case tcfWatchedThreads: watchedThreads(); break; case tcfWatchedForums: watchedForums(); break; case tcfAllForums: allForums(); break; case tcfNewPosts: newPosts(); break; case tcfSpy: spy(); break; case tcfHomeForum: homeForum(); break; case tcfUpForum: upForum(); break; case tcfGoToFirstUnread: goToFirstUnread(); break; } } } // Utility function to inject code within the page context function inject(f) { 'use strict'; var script; script = document.createElement('script'); script.type = 'text/javascript'; script.setAttribute('name', 'next_level_box'); script.textContent = '(' + f.toString() + ')($)'; document.head.appendChild(script); } // This is the actual code injected into the current page // for inhibiting the preview popup balloons when the // mouse is hovering over a thread link. function funcInjected($) { 'use strict'; function inhibitPreviewPopup() { // Add a mouseover event listener for PreviewTooltip element var elements = document.getElementsByClassName('PreviewTooltip'); for (var count = 0; count < elements.length; count++) { elements[count].parentNode.addEventListener("mouseover", mouseOver, true); } } function mouseOver(e) { // If we have an event object if (e) { // If the event object has a stopImmediatePropagation function if (e.stopImmediatePropagation) { // Call stopImmediatePropagation to inhibit the preview popup e.stopImmediatePropagation(); } else { // Some browsers don't support stopImmediatePropagation, // so in that case set the return value property to false. e.returnValue = false; } } } // Helps to update when update the page or its contents. $(document).ready(function () { inhibitPreviewPopup(); }); $(document).ajaxComplete(function () { inhibitPreviewPopup(); }); } // This function runs when document is ready. // This is an async command to work with GreaseMonkey's // async GM read/write in synchronous fashion. async function main() { 'use strict'; // If we want to inhibit preview popups, then inject the // necessary code. if (tcfInhibitPreviewPopup) { inject(funcInjected); } // Add event listeners for: // keydown to evalute hotkeys // beforeunload to track parent pages // load to continue processing mark forum read document.addEventListener("keydown", key_event, true); window.addEventListener('beforeunload', getCurrentForumPage, true); window.addEventListener('load', processMarkForumRead, true); // Grab the href for this page var href = document.URL; // If the href is one of the recognized root pages, pop other references off the stack if (href && href.endsWith("index.php?forums") || href.endsWith("index.php?spy/") || href.endsWith("index.php?watched/forums") || href.endsWith("index.php?watched/threads") || href.indexOf("index.php?find-new") >= 0 || href.endsWith("index.php")) { var hrefs = await GM.getValue("tcf_forum_page", "[]"); hrefs = JSON.parse(hrefs); if (hrefs && hrefs.length > 0) { if (href.endsWith("index.php?forums")) { while (hrefs.length > 0 && !hrefs[0].endsWith("index.php")) { hrefs.pop(); } } else { hrefs = []; } GM.setValue("tcf_forum_page", JSON.stringify(hrefs)); } } } main();