minimeh / TCF Web Tweaks

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