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