Facebook last post scroller

Automatically scroll to the last viewed or marked Facebook story

您需要先安裝使用者腳本管理器擴展,如 TampermonkeyGreasemonkeyViolentmonkey 之後才能安裝該腳本。

您需要先安裝使用者腳本管理器擴充功能,如 TampermonkeyViolentmonkey 後才能安裝該腳本。

您需要先安裝使用者腳本管理器擴充功能,如 TampermonkeyViolentmonkey 後才能安裝該腳本。

您需要先安裝使用者腳本管理器擴充功能,如 TampermonkeyUserscripts 後才能安裝該腳本。

你需要先安裝一款使用者腳本管理器擴展,比如 Tampermonkey,才能安裝此腳本

您需要先安裝使用者腳本管理器擴充功能後才能安裝該腳本。

(我已經安裝了使用者腳本管理器,讓我安裝!)

你需要先安裝一款使用者樣式管理器擴展,比如 Stylus,才能安裝此樣式

你需要先安裝一款使用者樣式管理器擴展,比如 Stylus,才能安裝此樣式

你需要先安裝一款使用者樣式管理器擴展,比如 Stylus,才能安裝此樣式

你需要先安裝一款使用者樣式管理器擴展後才能安裝此樣式

你需要先安裝一款使用者樣式管理器擴展後才能安裝此樣式

你需要先安裝一款使用者樣式管理器擴展後才能安裝此樣式

(我已經安裝了使用者樣式管理器,讓我安裝!)

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