HighlightsFeed.jsm 11 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267
  1. /* This Source Code Form is subject to the terms of the Mozilla Public
  2. * License, v. 2.0. If a copy of the MPL was not distributed with this
  3. * file, You can obtain one at http://mozilla.org/MPL/2.0/. */
  4. "use strict";
  5. const {Services} = ChromeUtils.import("resource://gre/modules/Services.jsm");
  6. const {actionTypes: at} = ChromeUtils.import("resource://activity-stream/common/Actions.jsm");
  7. const {shortURL} = ChromeUtils.import("resource://activity-stream/lib/ShortURL.jsm");
  8. const {SectionsManager} = ChromeUtils.import("resource://activity-stream/lib/SectionsManager.jsm");
  9. const {TOP_SITES_DEFAULT_ROWS, TOP_SITES_MAX_SITES_PER_ROW} = ChromeUtils.import("resource://activity-stream/common/Reducers.jsm");
  10. const {Dedupe} = ChromeUtils.import("resource://activity-stream/common/Dedupe.jsm");
  11. ChromeUtils.defineModuleGetter(this, "filterAdult",
  12. "resource://activity-stream/lib/FilterAdult.jsm");
  13. ChromeUtils.defineModuleGetter(this, "LinksCache",
  14. "resource://activity-stream/lib/LinksCache.jsm");
  15. ChromeUtils.defineModuleGetter(this, "NewTabUtils",
  16. "resource://gre/modules/NewTabUtils.jsm");
  17. ChromeUtils.defineModuleGetter(this, "Screenshots",
  18. "resource://activity-stream/lib/Screenshots.jsm");
  19. ChromeUtils.defineModuleGetter(this, "PageThumbs",
  20. "resource://gre/modules/PageThumbs.jsm");
  21. ChromeUtils.defineModuleGetter(this, "DownloadsManager",
  22. "resource://activity-stream/lib/DownloadsManager.jsm");
  23. const HIGHLIGHTS_MAX_LENGTH = 16;
  24. const MANY_EXTRA_LENGTH = HIGHLIGHTS_MAX_LENGTH * 5 + TOP_SITES_DEFAULT_ROWS * TOP_SITES_MAX_SITES_PER_ROW;
  25. const SECTION_ID = "highlights";
  26. const SYNC_BOOKMARKS_FINISHED_EVENT = "weave:engine:sync:applied";
  27. const BOOKMARKS_RESTORE_SUCCESS_EVENT = "bookmarks-restore-success";
  28. const BOOKMARKS_RESTORE_FAILED_EVENT = "bookmarks-restore-failed";
  29. const RECENT_DOWNLOAD_THRESHOLD = 36 * 60 * 60 * 1000;
  30. this.HighlightsFeed = class HighlightsFeed {
  31. constructor() {
  32. this.dedupe = new Dedupe(this._dedupeKey);
  33. this.linksCache = new LinksCache(NewTabUtils.activityStreamLinks,
  34. "getHighlights", ["image"]);
  35. PageThumbs.addExpirationFilter(this);
  36. this.downloadsManager = new DownloadsManager();
  37. }
  38. _dedupeKey(site) {
  39. // Treat bookmarks, pocket, and downloaded items as un-dedupable, otherwise show one of a url
  40. return site && ((site.pocket_id || site.type === "bookmark" || site.type === "download") ? {} : site.url);
  41. }
  42. init() {
  43. Services.obs.addObserver(this, SYNC_BOOKMARKS_FINISHED_EVENT);
  44. Services.obs.addObserver(this, BOOKMARKS_RESTORE_SUCCESS_EVENT);
  45. Services.obs.addObserver(this, BOOKMARKS_RESTORE_FAILED_EVENT);
  46. SectionsManager.onceInitialized(this.postInit.bind(this));
  47. }
  48. postInit() {
  49. SectionsManager.enableSection(SECTION_ID);
  50. this.fetchHighlights({broadcast: true});
  51. this.downloadsManager.init(this.store);
  52. }
  53. uninit() {
  54. SectionsManager.disableSection(SECTION_ID);
  55. PageThumbs.removeExpirationFilter(this);
  56. Services.obs.removeObserver(this, SYNC_BOOKMARKS_FINISHED_EVENT);
  57. Services.obs.removeObserver(this, BOOKMARKS_RESTORE_SUCCESS_EVENT);
  58. Services.obs.removeObserver(this, BOOKMARKS_RESTORE_FAILED_EVENT);
  59. }
  60. observe(subject, topic, data) {
  61. // When we receive a notification that a sync has happened for bookmarks,
  62. // or Places finished importing or restoring bookmarks, refresh highlights
  63. const manyBookmarksChanged =
  64. (topic === SYNC_BOOKMARKS_FINISHED_EVENT && data === "bookmarks") ||
  65. topic === BOOKMARKS_RESTORE_SUCCESS_EVENT ||
  66. topic === BOOKMARKS_RESTORE_FAILED_EVENT;
  67. if (manyBookmarksChanged) {
  68. this.fetchHighlights({broadcast: true});
  69. }
  70. }
  71. filterForThumbnailExpiration(callback) {
  72. const state = this.store.getState().Sections.find(section => section.id === SECTION_ID);
  73. callback(state && state.initialized ? state.rows.reduce((acc, site) => {
  74. // Screenshots call in `fetchImage` will search for preview_image_url or
  75. // fallback to URL, so we prevent both from being expired.
  76. acc.push(site.url);
  77. if (site.preview_image_url) {
  78. acc.push(site.preview_image_url);
  79. }
  80. return acc;
  81. }, []) : []);
  82. }
  83. /**
  84. * Chronologically sort highlights of all types except 'visited'. Then just append
  85. * the rest at the end of highlights.
  86. * @param {Array} pages The full list of links to order.
  87. * @return {Array} A sorted array of highlights
  88. */
  89. _orderHighlights(pages) {
  90. const splitHighlights = {chronologicalCandidates: [], visited: []};
  91. for (let page of pages) {
  92. // If we have a page that is both a history item and a bookmark, treat it
  93. // as a bookmark
  94. if (page.type === "history" && !page.bookmarkGuid) {
  95. splitHighlights.visited.push(page);
  96. } else {
  97. splitHighlights.chronologicalCandidates.push(page);
  98. }
  99. }
  100. return splitHighlights.chronologicalCandidates
  101. .sort((a, b) => a.date_added < b.date_added)
  102. .concat(splitHighlights.visited);
  103. }
  104. /**
  105. * Refresh the highlights data for content.
  106. * @param {bool} options.broadcast Should the update be broadcasted.
  107. */
  108. async fetchHighlights(options = {}) {
  109. // If TopSites are enabled we need them for deduping, so wait for
  110. // TOP_SITES_UPDATED. We also need the section to be registered to update
  111. // state, so wait for postInit triggered by SectionsManager initializing.
  112. if ((!this.store.getState().TopSites.initialized && this.store.getState().Prefs.values["feeds.topsites"]) ||
  113. !this.store.getState().Sections.length) {
  114. return;
  115. }
  116. // We broadcast when we want to force an update, so get fresh links
  117. if (options.broadcast) {
  118. this.linksCache.expire();
  119. }
  120. // Request more than the expected length to allow for items being removed by
  121. // deduping against Top Sites or multiple history from the same domain, etc.
  122. const manyPages = await this.linksCache.request({
  123. numItems: MANY_EXTRA_LENGTH,
  124. excludeBookmarks: !this.store.getState().Prefs.values["section.highlights.includeBookmarks"],
  125. excludeHistory: !this.store.getState().Prefs.values["section.highlights.includeVisited"],
  126. excludePocket: !this.store.getState().Prefs.values["section.highlights.includePocket"],
  127. });
  128. if (this.store.getState().Prefs.values["section.highlights.includeDownloads"]) {
  129. // We only want 1 download that is less than 36 hours old, and the file currently exists
  130. let results = await this.downloadsManager.getDownloads(RECENT_DOWNLOAD_THRESHOLD, {numItems: 1, onlySucceeded: true, onlyExists: true});
  131. if (results.length) {
  132. // We only want 1 download, the most recent one
  133. manyPages.push({
  134. ...results[0],
  135. type: "download",
  136. });
  137. }
  138. }
  139. const orderedPages = this._orderHighlights(manyPages);
  140. // Remove adult highlights if we need to
  141. const checkedAdult = this.store.getState().Prefs.values.filterAdult ?
  142. filterAdult(orderedPages) : orderedPages;
  143. // Remove any Highlights that are in Top Sites already
  144. const [, deduped] = this.dedupe.group(this.store.getState().TopSites.rows, checkedAdult);
  145. // Keep all "bookmark"s and at most one (most recent) "history" per host
  146. const highlights = [];
  147. const hosts = new Set();
  148. for (const page of deduped) {
  149. const hostname = shortURL(page);
  150. // Skip this history page if we already something from the same host
  151. if (page.type === "history" && hosts.has(hostname)) {
  152. continue;
  153. }
  154. // If we already have the image for the card, use that immediately. Else
  155. // asynchronously fetch the image. NEVER fetch a screenshot for downloads
  156. if (!page.image && page.type !== "download") {
  157. this.fetchImage(page);
  158. }
  159. // Adjust the type for 'history' items that are also 'bookmarked' when we
  160. // want to include bookmarks
  161. if (page.type === "history" && page.bookmarkGuid &&
  162. this.store.getState().Prefs.values["section.highlights.includeBookmarks"]) {
  163. page.type = "bookmark";
  164. }
  165. // We want the page, so update various fields for UI
  166. Object.assign(page, {
  167. hasImage: page.type !== "download", // Downloads do not have an image - all else types fall back to a screenshot
  168. hostname,
  169. type: page.type,
  170. pocket_id: page.pocket_id,
  171. });
  172. // Add the "bookmark", "pocket", or not-skipped "history"
  173. highlights.push(page);
  174. hosts.add(hostname);
  175. // Remove internal properties that might be updated after dispatch
  176. delete page.__sharedCache;
  177. // Skip the rest if we have enough items
  178. if (highlights.length === HIGHLIGHTS_MAX_LENGTH) {
  179. break;
  180. }
  181. }
  182. const {initialized} = this.store.getState().Sections.find(section => section.id === SECTION_ID);
  183. // Broadcast when required or if it is the first update.
  184. const shouldBroadcast = options.broadcast || !initialized;
  185. SectionsManager.updateSection(SECTION_ID, {rows: highlights}, shouldBroadcast);
  186. }
  187. /**
  188. * Fetch an image for a given highlight and update the card with it. If no
  189. * image is available then fallback to fetching a screenshot.
  190. */
  191. fetchImage(page) {
  192. // Request a screenshot if we don't already have one pending
  193. const {preview_image_url: imageUrl, url} = page;
  194. return Screenshots.maybeCacheScreenshot(page, imageUrl || url, "image", image => {
  195. SectionsManager.updateSectionCard(SECTION_ID, url, {image}, true);
  196. });
  197. }
  198. onAction(action) {
  199. // Relay the downloads actions to DownloadsManager - it is a child of HighlightsFeed
  200. this.downloadsManager.onAction(action);
  201. switch (action.type) {
  202. case at.INIT:
  203. this.init();
  204. break;
  205. case at.SYSTEM_TICK:
  206. case at.TOP_SITES_UPDATED:
  207. this.fetchHighlights({broadcast: false});
  208. break;
  209. case at.PREF_CHANGED:
  210. // Update existing pages when the user changes what should be shown
  211. if (action.data.name.startsWith("section.highlights.include")) {
  212. this.fetchHighlights({broadcast: true});
  213. }
  214. break;
  215. case at.PLACES_HISTORY_CLEARED:
  216. case at.PLACES_LINK_BLOCKED:
  217. case at.DOWNLOAD_CHANGED:
  218. case at.POCKET_LINK_DELETED_OR_ARCHIVED:
  219. this.fetchHighlights({broadcast: true});
  220. break;
  221. case at.PLACES_LINKS_CHANGED:
  222. case at.PLACES_SAVED_TO_POCKET:
  223. this.linksCache.expire();
  224. this.fetchHighlights({broadcast: false});
  225. break;
  226. case at.UNINIT:
  227. this.uninit();
  228. break;
  229. }
  230. }
  231. };
  232. const EXPORTED_SYMBOLS = ["HighlightsFeed", "SECTION_ID", "MANY_EXTRA_LENGTH", "SYNC_BOOKMARKS_FINISHED_EVENT", "BOOKMARKS_RESTORE_SUCCESS_EVENT", "BOOKMARKS_RESTORE_FAILED_EVENT"];