Facebook last post scroller

Automatically scroll to the last viewed or marked Facebook story

// ==UserScript==
// @name        Facebook last post scroller
// @namespace   https://github.com/soufianesakhi/facebook-last-post-scroller
// @description Automatically scroll to the last viewed or marked Facebook story
// @author      https://github.com/soufianesakhi
// @copyright   2016-2017, Soufiane Sakhi
// @license     MIT; https://opensource.org/licenses/MIT
// @homepage    https://github.com/soufianesakhi/facebook-last-post-scroller
// @supportURL  https://github.com/soufianesakhi/facebook-last-post-scroller/issues
// @icon        https://cdn3.iconfinder.com/data/icons/watchify-v1-0-80px/80/arrow-down-80px-128.png
// @require     http://code.jquery.com/jquery.min.js
// @require     https://greasyfork.org/scripts/19857-node-creation-observer/code/node-creation-observer.js?version=174436
// @include     https://www.facebook.com/*
// @include     https://web.facebook.com/*
// @version     1.3.1
// @grant       GM_setValue
// @grant       GM_getValue
// ==/UserScript==

/// <reference path="../typings/index.d.ts" />

var storySelector = "[id^='hyperfeed_story_id']";
var subStorySelector = ".userContentWrapper";
var scrollerBtnPredecessorSelector = "#pagelet_composer";
var storyLinkSelector = "div._5pcp span > span > a._5pcq[target]";
var lastPostButtonAppendSelector = "div._5pcp";
var blueBarId = "pagelet_bluebar";
var timestampAttribute = "data-timestamp";
var loadedStoryByPage = 10;
var fbUrlPatterns = [
    new RegExp("https?:\/\/(web|www)\.facebook\.com\/\\?sk\=h_chr", "i"),
    new RegExp("https?:\/\/(web|www)\.facebook\.com\/?$", "i"),
    new RegExp("https?:\/\/(web|www)\.facebook\.com\/\\?ref\=logo", "i")];

var lastPostIconLink = "https://cdn3.iconfinder.com/data/icons/watchify-v1-0-80px/80/arrow-down-80px-128.png";
var iconStyle = "vertical-align: middle; height: 20px; width: 20px; cursor: pointer;";

var lastPostSeparatorTitle = "End of new posts";
var scriptId = "FBLastPost";
var menuId = "FBLastPostMenu";
var lastPostSeparatorId = scriptId + "Separator";
var lastPostURIKey = scriptId + "URI";
var lastPostTimestampKey = scriptId + "Timestamp";
var lastPostScrollerId = getId("Scroller");
var reverseSortLoaderId = getId("ReverseSortLoader");

var lastPostURI = GM_getValue(lastPostURIKey, null);
var lastPostTimestamp = GM_getValue(lastPostTimestampKey, 0);
var storyCount = 0;

/** @type {MutationObserver[]} */
var storyLoadObservers = [];
/** @type {Element[]} */
var loadedStories = [];
/** @type {Element[]} */
var checkedStories = [];
/** @type {Number} */
var previousScrollHeight;
var stopped = false;
var isMostRecentMode = false;
var isHome = false;
var currentURL = null;
/** @type {JQuery} */
var loadingToolbar;
/** @type {JQuery} */
var loadingProgress;
/** @type {Number} */
var timeSinceLastpost;

$(document).ready(function () {
    initLastPostButtonObserver();
    initButtons();
});

function initLastPostButtonObserver() {
    NodeCreationObserver.onCreation(lastPostButtonAppendSelector, function (storyDetailsElement) {
        checkURLChange();
        if (!isHomeMostRecent()) {
            return;
        }
        var storyElement = $(storyDetailsElement).closest(storySelector);
        var storyId = storyElement.attr('id');
        var lastPostIconId = getId(storyId);
        $(storyDetailsElement).append('<span id="' + lastPostIconId + '" > <abbr title="Set as last post"><img src="' + lastPostIconLink + '" style="' + iconStyle + '" /></abbr></span>');
        $("#" + lastPostIconId).click(function () {
            if (confirm("Set this post as the last ?")) {
                var storyElement = $(this).closest(storySelector);
                setLastPost(storyElement);
            }
        });
    });
}

function initLoadingToolbar() {
    timeSinceLastpost = Math.floor(Date.now() / 1000) - lastPostTimestamp;
    console.log('timeSinceLastpost: ' + timeSinceLastpost);
    loadingToolbar = $("<div>", {
        id: getId("LoadingToolbar"),
        style: "position: fixed; top: 50px; left: 300px; width: 400px; z-index: 9999; background-color: beige; padding: 10px; border: 1px solid grey; border-radius: 2px;"
    }).insertAfter(scrollerBtnPredecessorSelector);
    $("<img>", {
        src: lastPostIconLink,
        style: "position: absolute; top: 5px; right: 10px; height: 30px; width: 30px; "
    }).appendTo(loadingToolbar);
    var stopLoadingBtn = $("<button>", {
        id: getId("StopLoading"),
        type: "submit",
        style: "cursor: pointer; color: buttontext; background-color: buttonface;"
    }).text("Stop loading & scrolling").appendTo(loadingToolbar);
    loadingProgress = $("<progress>", {
        value: 0,
        max: 100,
        style: "margin-left: 40px;"
    }).appendTo(loadingToolbar);
    stopLoadingBtn.click(stopLoading);
}

function updateProgress(currentTimestamp) {
    var progress = 100 - 100 * (currentTimestamp - lastPostTimestamp) / timeSinceLastpost;
    loadingProgress.attr("value", progress);
}

function getButton(id, title) {
    return '<button id="' + id
        + '" type="submit" style="margin-left: 2%; cursor: pointer;"><img src="'
        + lastPostIconLink + '" style="' + iconStyle + '" />' + title
        + '</button>';
}

function getMenu(children) {
    return '<div id="' + menuId + '" style="text-align: center;" >' + children + '</div>';
}

function initButtons() {
    NodeCreationObserver.onCreation(scrollerBtnPredecessorSelector, function (predecessor) {
        checkURLChange();
        if (!isHomeMostRecent()) {
            return;
        }
        var children = getButton(lastPostScrollerId, "Scroll to last post");
        children += getButton(reverseSortLoaderId, "Load last post and revese sort stories");
        $(predecessor).after(getMenu(children));
        $("#" + lastPostScrollerId).click(startLoading);
        $("#" + reverseSortLoaderId).click(startLoading);
    });
}

/**
 * @param {JQueryEventObject} eventObject 
 */
function startLoading(eventObject) {
    var reverseSort = eventObject.target.id === reverseSortLoaderId;
    $("#" + menuId).hide();
    initLoadingToolbar();
    NodeCreationObserver.onCreation(storySelector, function (element) {
        if (stopped) {
            return;
        }
        storyCount++;
        if (loadedStories.indexOf(element) == -1) {
            loadedStories.push(element);
        }
        if (storyCount % loadedStoryByPage == 0) {
            waitForStoriesToLoad(element.id, storyCount, reverseSort);
            return;
        }
        if (storyCount == 1) {
            if (lastPostURI == null) {
                NodeCreationObserver.remove(storySelector);
                stopped = true;
                return;
            }
            searchForStory(reverseSort);
        } else if (storyCount == 2) {
            searchForStory(reverseSort);
            scrollToBottom();
            storyCount = 10;
        }
    });
}

function checkURLChange() {
    var url = document.URL;
    if (url !== currentURL) {
        currentURL = url;
        isHome = matchesFBHomeURL();
        if (isHome) {
            checkMostRecentMode();
        }
    }
}

function isHomeMostRecent() {
    return isHome && isMostRecentMode;
}

function checkMostRecentMode() {
    var element = $("#stream_pagelet a[href^='/?sk=h_nor']");
    var elementExist = element.length == 1;
    isMostRecentMode = elementExist && element.is(':visible');
}

function matchesFBHomeURL() {
    var isHome = false;
    fbUrlPatterns.forEach(function (pattern) {
        if (pattern.test(currentURL)) {
            isHome = true;
        }
    });
    return isHome;
}

function setLastPost(storyElement) {
    var uri = getStoryURI(storyElement);
    var timestamp = getStoryTimestamp(storyElement);
    GM_setValue(lastPostURIKey, uri);
    GM_setValue(lastPostTimestampKey, timestamp);
    console.log("Setting last post: " + uri + " (timestamp: " + timestamp + ")");
}

function getId(elementId) {
    return scriptId + "-" + elementId;
}

/**
 * @param {string} id 
 * @param {number} count 
 * @param {boolean} reverseSort 
 */
function waitForStoriesToLoad(id, count, reverseSort) {
    var mutationObserver = new MutationObserver(function (elements, observer) {
        var loadedStories = storyCount - count;
        if (stopped) {
            observer.disconnect();
            return;
        }
        if (loadedStories > loadedStoryByPage - 1) {
            observer.disconnect();
            storyLoadObservers = removeFromArray(storyLoadObservers, observer);
            searchForStory(reverseSort);
        } else {
            scrollToBottom();
        }
    });
    storyLoadObservers.push(mutationObserver);
    mutationObserver.observe(document.documentElement, {
        childList: true,
        subtree: true
    });
}

/**
 * @param {boolean} reverseSort 
 */
function searchForStory(reverseSort) {
    loadedStories.forEach(function (element) {
        if (!stopped && checkedStories.indexOf(element) == -1) {
            var uri = getStoryURI(element);
            if (uri != null) {
                checkedStories.push(element);
                var ts = getStoryTimestamp(element);
                updateProgress(ts);
                var notSuggested = notSuggestedStory(element);
                if (uri === lastPostURI) {
                    stopSearching(element.id, reverseSort);
                } else if (ts < lastPostTimestamp && notSuggested) {
                    stopSearching(element.id, reverseSort);
                    console.log("The last post was not found: " + lastPostURI + " (" + lastPostTimestamp + ")");
                    console.log("Stopped at the story: " + uri + " (" + ts + ")" + (notSuggested ? "" : " (suggested story)"));
                }
            }
        }
    });
}

function notSuggestedStory(storyElement) {
    if ($(storyElement).find("img[alt=explore]").length > 0) {
        return false;
    }
    var div = $(storyElement).find("._5g-l");
    var notSuggested = div.length == 0 || div.find(".profileLink").length > 0;
    if (notSuggested) {
        notSuggested = $(storyElement).find("span:not([class]) > span[class]:not(:has(*))").length == 0;
    }
    return notSuggested;
}

function getStoryTimestamp(storyElement) {
    return Number($(storyElement).attr(timestampAttribute));
}

function getStoryURI(storyElement) {
    var aLink = $(storyElement).find(storyLinkSelector);
    if (aLink != null) {
        return aLink.attr("href");
    }
    return null;
}

function stopLoading() {
    loadingToolbar.hide();
    stopped = true;
    NodeCreationObserver.remove(storySelector);
    storyLoadObservers.forEach(function (observer) {
        observer.disconnect();
    });
    storyLoadObservers = [];
}

/**
 * @param {string} id 
 * @param {boolean} reverseSort 
 */
function stopSearching(id, reverseSort) {
    setLastPost(checkedStories[0]);
    stopLoading();
    var lastPostSeparator = $("<div>", {
        id: lastPostSeparatorId,
        style: 'margin-bottom: 10px; text-align: center;'
    }).text(lastPostSeparatorTitle);
    $("#" + id).before(lastPostSeparator);
    if (reverseSort) {
        window.scrollTo(0, 0);
        var timestamps = [];
        var parent = $(checkedStories[0]).parent();
        for (var i = 1; i < checkedStories.length - 1; i++) {
            timestamps.push(getStoryTimestamp(checkedStories[i]));
            $(checkedStories[i]).detach().prependTo(parent);
        }
    } else {
        var offsetHeight = document.getElementById(blueBarId).offsetHeight;
        var height = lastPostSeparator[0].offsetTop;
        var y = height - offsetHeight;
        window.scrollTo(0, y > 0 ? y : 0);
    }
}

function removeFromArray(array, element) {
    var index = array.indexOf(element);
    if (index > -1) {
        return array.splice(index, 1);
    }
    return array;
}

function scrollToBottom() {
    var currentScrollHeight = document.body.scrollHeight;
    if (previousScrollHeight !== currentScrollHeight) {
        previousScrollHeight = currentScrollHeight;
        window.scrollTo(0, currentScrollHeight);
    }
}