Facebook last post scroller

Automatically scroll to the last viewed or marked Facebook story

  1. // ==UserScript==
  2. // @name Facebook last post scroller
  3. // @namespace https://github.com/soufianesakhi/facebook-last-post-scroller
  4. // @description Automatically scroll to the last viewed or marked Facebook story
  5. // @author https://github.com/soufianesakhi
  6. // @copyright 2016-2017, Soufiane Sakhi
  7. // @license MIT; https://opensource.org/licenses/MIT
  8. // @homepage https://github.com/soufianesakhi/facebook-last-post-scroller
  9. // @supportURL https://github.com/soufianesakhi/facebook-last-post-scroller/issues
  10. // @icon https://cdn3.iconfinder.com/data/icons/watchify-v1-0-80px/80/arrow-down-80px-128.png
  11. // @require http://code.jquery.com/jquery.min.js
  12. // @require https://greasyfork.org/scripts/19857-node-creation-observer/code/node-creation-observer.js?version=174436
  13. // @include https://www.facebook.com/*
  14. // @include https://web.facebook.com/*
  15. // @version 1.3.1
  16. // @grant GM_setValue
  17. // @grant GM_getValue
  18. // ==/UserScript==
  19.  
  20. /// <reference path="../typings/index.d.ts" />
  21.  
  22. var storySelector = "[id^='hyperfeed_story_id']";
  23. var subStorySelector = ".userContentWrapper";
  24. var scrollerBtnPredecessorSelector = "#pagelet_composer";
  25. var storyLinkSelector = "div._5pcp span > span > a._5pcq[target]";
  26. var lastPostButtonAppendSelector = "div._5pcp";
  27. var blueBarId = "pagelet_bluebar";
  28. var timestampAttribute = "data-timestamp";
  29. var loadedStoryByPage = 10;
  30. var fbUrlPatterns = [
  31. new RegExp("https?:\/\/(web|www)\.facebook\.com\/\\?sk\=h_chr", "i"),
  32. new RegExp("https?:\/\/(web|www)\.facebook\.com\/?$", "i"),
  33. new RegExp("https?:\/\/(web|www)\.facebook\.com\/\\?ref\=logo", "i")];
  34.  
  35. var lastPostIconLink = "https://cdn3.iconfinder.com/data/icons/watchify-v1-0-80px/80/arrow-down-80px-128.png";
  36. var iconStyle = "vertical-align: middle; height: 20px; width: 20px; cursor: pointer;";
  37.  
  38. var lastPostSeparatorTitle = "End of new posts";
  39. var scriptId = "FBLastPost";
  40. var menuId = "FBLastPostMenu";
  41. var lastPostSeparatorId = scriptId + "Separator";
  42. var lastPostURIKey = scriptId + "URI";
  43. var lastPostTimestampKey = scriptId + "Timestamp";
  44. var lastPostScrollerId = getId("Scroller");
  45. var reverseSortLoaderId = getId("ReverseSortLoader");
  46.  
  47. var lastPostURI = GM_getValue(lastPostURIKey, null);
  48. var lastPostTimestamp = GM_getValue(lastPostTimestampKey, 0);
  49. var storyCount = 0;
  50.  
  51. /** @type {MutationObserver[]} */
  52. var storyLoadObservers = [];
  53. /** @type {Element[]} */
  54. var loadedStories = [];
  55. /** @type {Element[]} */
  56. var checkedStories = [];
  57. /** @type {Number} */
  58. var previousScrollHeight;
  59. var stopped = false;
  60. var isMostRecentMode = false;
  61. var isHome = false;
  62. var currentURL = null;
  63. /** @type {JQuery} */
  64. var loadingToolbar;
  65. /** @type {JQuery} */
  66. var loadingProgress;
  67. /** @type {Number} */
  68. var timeSinceLastpost;
  69.  
  70. $(document).ready(function () {
  71. initLastPostButtonObserver();
  72. initButtons();
  73. });
  74.  
  75. function initLastPostButtonObserver() {
  76. NodeCreationObserver.onCreation(lastPostButtonAppendSelector, function (storyDetailsElement) {
  77. checkURLChange();
  78. if (!isHomeMostRecent()) {
  79. return;
  80. }
  81. var storyElement = $(storyDetailsElement).closest(storySelector);
  82. var storyId = storyElement.attr('id');
  83. var lastPostIconId = getId(storyId);
  84. $(storyDetailsElement).append('<span id="' + lastPostIconId + '" > <abbr title="Set as last post"><img src="' + lastPostIconLink + '" style="' + iconStyle + '" /></abbr></span>');
  85. $("#" + lastPostIconId).click(function () {
  86. if (confirm("Set this post as the last ?")) {
  87. var storyElement = $(this).closest(storySelector);
  88. setLastPost(storyElement);
  89. }
  90. });
  91. });
  92. }
  93.  
  94. function initLoadingToolbar() {
  95. timeSinceLastpost = Math.floor(Date.now() / 1000) - lastPostTimestamp;
  96. console.log('timeSinceLastpost: ' + timeSinceLastpost);
  97. loadingToolbar = $("<div>", {
  98. id: getId("LoadingToolbar"),
  99. style: "position: fixed; top: 50px; left: 300px; width: 400px; z-index: 9999; background-color: beige; padding: 10px; border: 1px solid grey; border-radius: 2px;"
  100. }).insertAfter(scrollerBtnPredecessorSelector);
  101. $("<img>", {
  102. src: lastPostIconLink,
  103. style: "position: absolute; top: 5px; right: 10px; height: 30px; width: 30px; "
  104. }).appendTo(loadingToolbar);
  105. var stopLoadingBtn = $("<button>", {
  106. id: getId("StopLoading"),
  107. type: "submit",
  108. style: "cursor: pointer; color: buttontext; background-color: buttonface;"
  109. }).text("Stop loading & scrolling").appendTo(loadingToolbar);
  110. loadingProgress = $("<progress>", {
  111. value: 0,
  112. max: 100,
  113. style: "margin-left: 40px;"
  114. }).appendTo(loadingToolbar);
  115. stopLoadingBtn.click(stopLoading);
  116. }
  117.  
  118. function updateProgress(currentTimestamp) {
  119. var progress = 100 - 100 * (currentTimestamp - lastPostTimestamp) / timeSinceLastpost;
  120. loadingProgress.attr("value", progress);
  121. }
  122.  
  123. function getButton(id, title) {
  124. return '<button id="' + id
  125. + '" type="submit" style="margin-left: 2%; cursor: pointer;"><img src="'
  126. + lastPostIconLink + '" style="' + iconStyle + '" />' + title
  127. + '</button>';
  128. }
  129.  
  130. function getMenu(children) {
  131. return '<div id="' + menuId + '" style="text-align: center;" >' + children + '</div>';
  132. }
  133.  
  134. function initButtons() {
  135. NodeCreationObserver.onCreation(scrollerBtnPredecessorSelector, function (predecessor) {
  136. checkURLChange();
  137. if (!isHomeMostRecent()) {
  138. return;
  139. }
  140. var children = getButton(lastPostScrollerId, "Scroll to last post");
  141. children += getButton(reverseSortLoaderId, "Load last post and revese sort stories");
  142. $(predecessor).after(getMenu(children));
  143. $("#" + lastPostScrollerId).click(startLoading);
  144. $("#" + reverseSortLoaderId).click(startLoading);
  145. });
  146. }
  147.  
  148. /**
  149. * @param {JQueryEventObject} eventObject
  150. */
  151. function startLoading(eventObject) {
  152. var reverseSort = eventObject.target.id === reverseSortLoaderId;
  153. $("#" + menuId).hide();
  154. initLoadingToolbar();
  155. NodeCreationObserver.onCreation(storySelector, function (element) {
  156. if (stopped) {
  157. return;
  158. }
  159. storyCount++;
  160. if (loadedStories.indexOf(element) == -1) {
  161. loadedStories.push(element);
  162. }
  163. if (storyCount % loadedStoryByPage == 0) {
  164. waitForStoriesToLoad(element.id, storyCount, reverseSort);
  165. return;
  166. }
  167. if (storyCount == 1) {
  168. if (lastPostURI == null) {
  169. NodeCreationObserver.remove(storySelector);
  170. stopped = true;
  171. return;
  172. }
  173. searchForStory(reverseSort);
  174. } else if (storyCount == 2) {
  175. searchForStory(reverseSort);
  176. scrollToBottom();
  177. storyCount = 10;
  178. }
  179. });
  180. }
  181.  
  182. function checkURLChange() {
  183. var url = document.URL;
  184. if (url !== currentURL) {
  185. currentURL = url;
  186. isHome = matchesFBHomeURL();
  187. if (isHome) {
  188. checkMostRecentMode();
  189. }
  190. }
  191. }
  192.  
  193. function isHomeMostRecent() {
  194. return isHome && isMostRecentMode;
  195. }
  196.  
  197. function checkMostRecentMode() {
  198. var element = $("#stream_pagelet a[href^='/?sk=h_nor']");
  199. var elementExist = element.length == 1;
  200. isMostRecentMode = elementExist && element.is(':visible');
  201. }
  202.  
  203. function matchesFBHomeURL() {
  204. var isHome = false;
  205. fbUrlPatterns.forEach(function (pattern) {
  206. if (pattern.test(currentURL)) {
  207. isHome = true;
  208. }
  209. });
  210. return isHome;
  211. }
  212.  
  213. function setLastPost(storyElement) {
  214. var uri = getStoryURI(storyElement);
  215. var timestamp = getStoryTimestamp(storyElement);
  216. GM_setValue(lastPostURIKey, uri);
  217. GM_setValue(lastPostTimestampKey, timestamp);
  218. console.log("Setting last post: " + uri + " (timestamp: " + timestamp + ")");
  219. }
  220.  
  221. function getId(elementId) {
  222. return scriptId + "-" + elementId;
  223. }
  224.  
  225. /**
  226. * @param {string} id
  227. * @param {number} count
  228. * @param {boolean} reverseSort
  229. */
  230. function waitForStoriesToLoad(id, count, reverseSort) {
  231. var mutationObserver = new MutationObserver(function (elements, observer) {
  232. var loadedStories = storyCount - count;
  233. if (stopped) {
  234. observer.disconnect();
  235. return;
  236. }
  237. if (loadedStories > loadedStoryByPage - 1) {
  238. observer.disconnect();
  239. storyLoadObservers = removeFromArray(storyLoadObservers, observer);
  240. searchForStory(reverseSort);
  241. } else {
  242. scrollToBottom();
  243. }
  244. });
  245. storyLoadObservers.push(mutationObserver);
  246. mutationObserver.observe(document.documentElement, {
  247. childList: true,
  248. subtree: true
  249. });
  250. }
  251.  
  252. /**
  253. * @param {boolean} reverseSort
  254. */
  255. function searchForStory(reverseSort) {
  256. loadedStories.forEach(function (element) {
  257. if (!stopped && checkedStories.indexOf(element) == -1) {
  258. var uri = getStoryURI(element);
  259. if (uri != null) {
  260. checkedStories.push(element);
  261. var ts = getStoryTimestamp(element);
  262. updateProgress(ts);
  263. var notSuggested = notSuggestedStory(element);
  264. if (uri === lastPostURI) {
  265. stopSearching(element.id, reverseSort);
  266. } else if (ts < lastPostTimestamp && notSuggested) {
  267. stopSearching(element.id, reverseSort);
  268. console.log("The last post was not found: " + lastPostURI + " (" + lastPostTimestamp + ")");
  269. console.log("Stopped at the story: " + uri + " (" + ts + ")" + (notSuggested ? "" : " (suggested story)"));
  270. }
  271. }
  272. }
  273. });
  274. }
  275.  
  276. function notSuggestedStory(storyElement) {
  277. if ($(storyElement).find("img[alt=explore]").length > 0) {
  278. return false;
  279. }
  280. var div = $(storyElement).find("._5g-l");
  281. var notSuggested = div.length == 0 || div.find(".profileLink").length > 0;
  282. if (notSuggested) {
  283. notSuggested = $(storyElement).find("span:not([class]) > span[class]:not(:has(*))").length == 0;
  284. }
  285. return notSuggested;
  286. }
  287.  
  288. function getStoryTimestamp(storyElement) {
  289. return Number($(storyElement).attr(timestampAttribute));
  290. }
  291.  
  292. function getStoryURI(storyElement) {
  293. var aLink = $(storyElement).find(storyLinkSelector);
  294. if (aLink != null) {
  295. return aLink.attr("href");
  296. }
  297. return null;
  298. }
  299.  
  300. function stopLoading() {
  301. loadingToolbar.hide();
  302. stopped = true;
  303. NodeCreationObserver.remove(storySelector);
  304. storyLoadObservers.forEach(function (observer) {
  305. observer.disconnect();
  306. });
  307. storyLoadObservers = [];
  308. }
  309.  
  310. /**
  311. * @param {string} id
  312. * @param {boolean} reverseSort
  313. */
  314. function stopSearching(id, reverseSort) {
  315. setLastPost(checkedStories[0]);
  316. stopLoading();
  317. var lastPostSeparator = $("<div>", {
  318. id: lastPostSeparatorId,
  319. style: 'margin-bottom: 10px; text-align: center;'
  320. }).text(lastPostSeparatorTitle);
  321. $("#" + id).before(lastPostSeparator);
  322. if (reverseSort) {
  323. window.scrollTo(0, 0);
  324. var timestamps = [];
  325. var parent = $(checkedStories[0]).parent();
  326. for (var i = 1; i < checkedStories.length - 1; i++) {
  327. timestamps.push(getStoryTimestamp(checkedStories[i]));
  328. $(checkedStories[i]).detach().prependTo(parent);
  329. }
  330. } else {
  331. var offsetHeight = document.getElementById(blueBarId).offsetHeight;
  332. var height = lastPostSeparator[0].offsetTop;
  333. var y = height - offsetHeight;
  334. window.scrollTo(0, y > 0 ? y : 0);
  335. }
  336. }
  337.  
  338. function removeFromArray(array, element) {
  339. var index = array.indexOf(element);
  340. if (index > -1) {
  341. return array.splice(index, 1);
  342. }
  343. return array;
  344. }
  345.  
  346. function scrollToBottom() {
  347. var currentScrollHeight = document.body.scrollHeight;
  348. if (previousScrollHeight !== currentScrollHeight) {
  349. previousScrollHeight = currentScrollHeight;
  350. window.scrollTo(0, currentScrollHeight);
  351. }
  352. }