summaryrefslogtreecommitdiffstats
path: root/browser/components/newtab/lib/HighlightsFeed.jsm
diff options
context:
space:
mode:
Diffstat (limited to 'browser/components/newtab/lib/HighlightsFeed.jsm')
-rw-r--r--browser/components/newtab/lib/HighlightsFeed.jsm350
1 files changed, 350 insertions, 0 deletions
diff --git a/browser/components/newtab/lib/HighlightsFeed.jsm b/browser/components/newtab/lib/HighlightsFeed.jsm
new file mode 100644
index 0000000000..45a30bfa97
--- /dev/null
+++ b/browser/components/newtab/lib/HighlightsFeed.jsm
@@ -0,0 +1,350 @@
+/* This Source Code Form is subject to the terms of the Mozilla Public
+ * License, v. 2.0. If a copy of the MPL was not distributed with this
+ * file, You can obtain one at http://mozilla.org/MPL/2.0/. */
+"use strict";
+
+const { actionTypes: at } = ChromeUtils.importESModule(
+ "resource://activity-stream/common/Actions.sys.mjs"
+);
+
+const { shortURL } = ChromeUtils.import(
+ "resource://activity-stream/lib/ShortURL.jsm"
+);
+const { SectionsManager } = ChromeUtils.import(
+ "resource://activity-stream/lib/SectionsManager.jsm"
+);
+const { TOP_SITES_DEFAULT_ROWS, TOP_SITES_MAX_SITES_PER_ROW } =
+ ChromeUtils.importESModule(
+ "resource://activity-stream/common/Reducers.sys.mjs"
+ );
+const { Dedupe } = ChromeUtils.importESModule(
+ "resource://activity-stream/common/Dedupe.sys.mjs"
+);
+
+const lazy = {};
+
+ChromeUtils.defineModuleGetter(
+ lazy,
+ "FilterAdult",
+ "resource://activity-stream/lib/FilterAdult.jsm"
+);
+ChromeUtils.defineESModuleGetters(lazy, {
+ LinksCache: "resource://activity-stream/lib/LinksCache.sys.mjs",
+ NewTabUtils: "resource://gre/modules/NewTabUtils.sys.mjs",
+ PageThumbs: "resource://gre/modules/PageThumbs.sys.mjs",
+});
+ChromeUtils.defineModuleGetter(
+ lazy,
+ "Screenshots",
+ "resource://activity-stream/lib/Screenshots.jsm"
+);
+ChromeUtils.defineModuleGetter(
+ lazy,
+ "DownloadsManager",
+ "resource://activity-stream/lib/DownloadsManager.jsm"
+);
+
+const HIGHLIGHTS_MAX_LENGTH = 16;
+const MANY_EXTRA_LENGTH =
+ HIGHLIGHTS_MAX_LENGTH * 5 +
+ TOP_SITES_DEFAULT_ROWS * TOP_SITES_MAX_SITES_PER_ROW;
+const SECTION_ID = "highlights";
+const SYNC_BOOKMARKS_FINISHED_EVENT = "weave:engine:sync:applied";
+const BOOKMARKS_RESTORE_SUCCESS_EVENT = "bookmarks-restore-success";
+const BOOKMARKS_RESTORE_FAILED_EVENT = "bookmarks-restore-failed";
+const RECENT_DOWNLOAD_THRESHOLD = 36 * 60 * 60 * 1000;
+
+class HighlightsFeed {
+ constructor() {
+ this.dedupe = new Dedupe(this._dedupeKey);
+ this.linksCache = new lazy.LinksCache(
+ lazy.NewTabUtils.activityStreamLinks,
+ "getHighlights",
+ ["image"]
+ );
+ lazy.PageThumbs.addExpirationFilter(this);
+ this.downloadsManager = new lazy.DownloadsManager();
+ }
+
+ _dedupeKey(site) {
+ // Treat bookmarks, pocket, and downloaded items as un-dedupable, otherwise show one of a url
+ return (
+ site &&
+ (site.pocket_id || site.type === "bookmark" || site.type === "download"
+ ? {}
+ : site.url)
+ );
+ }
+
+ init() {
+ Services.obs.addObserver(this, SYNC_BOOKMARKS_FINISHED_EVENT);
+ Services.obs.addObserver(this, BOOKMARKS_RESTORE_SUCCESS_EVENT);
+ Services.obs.addObserver(this, BOOKMARKS_RESTORE_FAILED_EVENT);
+ SectionsManager.onceInitialized(this.postInit.bind(this));
+ }
+
+ postInit() {
+ SectionsManager.enableSection(SECTION_ID, true /* isStartup */);
+ this.fetchHighlights({ broadcast: true, isStartup: true });
+ this.downloadsManager.init(this.store);
+ }
+
+ uninit() {
+ SectionsManager.disableSection(SECTION_ID);
+ lazy.PageThumbs.removeExpirationFilter(this);
+ Services.obs.removeObserver(this, SYNC_BOOKMARKS_FINISHED_EVENT);
+ Services.obs.removeObserver(this, BOOKMARKS_RESTORE_SUCCESS_EVENT);
+ Services.obs.removeObserver(this, BOOKMARKS_RESTORE_FAILED_EVENT);
+ }
+
+ observe(subject, topic, data) {
+ // When we receive a notification that a sync has happened for bookmarks,
+ // or Places finished importing or restoring bookmarks, refresh highlights
+ const manyBookmarksChanged =
+ (topic === SYNC_BOOKMARKS_FINISHED_EVENT && data === "bookmarks") ||
+ topic === BOOKMARKS_RESTORE_SUCCESS_EVENT ||
+ topic === BOOKMARKS_RESTORE_FAILED_EVENT;
+ if (manyBookmarksChanged) {
+ this.fetchHighlights({ broadcast: true });
+ }
+ }
+
+ filterForThumbnailExpiration(callback) {
+ const state = this.store
+ .getState()
+ .Sections.find(section => section.id === SECTION_ID);
+
+ callback(
+ state && state.initialized
+ ? state.rows.reduce((acc, site) => {
+ // Screenshots call in `fetchImage` will search for preview_image_url or
+ // fallback to URL, so we prevent both from being expired.
+ acc.push(site.url);
+ if (site.preview_image_url) {
+ acc.push(site.preview_image_url);
+ }
+ return acc;
+ }, [])
+ : []
+ );
+ }
+
+ /**
+ * Chronologically sort highlights of all types except 'visited'. Then just append
+ * the rest at the end of highlights.
+ * @param {Array} pages The full list of links to order.
+ * @return {Array} A sorted array of highlights
+ */
+ _orderHighlights(pages) {
+ const splitHighlights = { chronologicalCandidates: [], visited: [] };
+ for (let page of pages) {
+ if (page.type === "history") {
+ splitHighlights.visited.push(page);
+ } else {
+ splitHighlights.chronologicalCandidates.push(page);
+ }
+ }
+
+ return splitHighlights.chronologicalCandidates
+ .sort((a, b) => a.date_added < b.date_added)
+ .concat(splitHighlights.visited);
+ }
+
+ /**
+ * Refresh the highlights data for content.
+ * @param {bool} options.broadcast Should the update be broadcasted.
+ */
+ async fetchHighlights(options = {}) {
+ // If TopSites are enabled we need them for deduping, so wait for
+ // TOP_SITES_UPDATED. We also need the section to be registered to update
+ // state, so wait for postInit triggered by SectionsManager initializing.
+ if (
+ (!this.store.getState().TopSites.initialized &&
+ this.store.getState().Prefs.values["feeds.system.topsites"] &&
+ this.store.getState().Prefs.values["feeds.topsites"]) ||
+ !this.store.getState().Sections.length
+ ) {
+ return;
+ }
+
+ // We broadcast when we want to force an update, so get fresh links
+ if (options.broadcast) {
+ this.linksCache.expire();
+ }
+
+ // Request more than the expected length to allow for items being removed by
+ // deduping against Top Sites or multiple history from the same domain, etc.
+ const manyPages = await this.linksCache.request({
+ numItems: MANY_EXTRA_LENGTH,
+ excludeBookmarks:
+ !this.store.getState().Prefs.values[
+ "section.highlights.includeBookmarks"
+ ],
+ excludeHistory:
+ !this.store.getState().Prefs.values[
+ "section.highlights.includeVisited"
+ ],
+ excludePocket:
+ !this.store.getState().Prefs.values["section.highlights.includePocket"],
+ });
+
+ if (
+ this.store.getState().Prefs.values["section.highlights.includeDownloads"]
+ ) {
+ // We only want 1 download that is less than 36 hours old, and the file currently exists
+ let results = await this.downloadsManager.getDownloads(
+ RECENT_DOWNLOAD_THRESHOLD,
+ { numItems: 1, onlySucceeded: true, onlyExists: true }
+ );
+ if (results.length) {
+ // We only want 1 download, the most recent one
+ manyPages.push({
+ ...results[0],
+ type: "download",
+ });
+ }
+ }
+
+ const orderedPages = this._orderHighlights(manyPages);
+
+ // Remove adult highlights if we need to
+ const checkedAdult = lazy.FilterAdult.filter(orderedPages);
+
+ // Remove any Highlights that are in Top Sites already
+ const [, deduped] = this.dedupe.group(
+ this.store.getState().TopSites.rows,
+ checkedAdult
+ );
+
+ // Keep all "bookmark"s and at most one (most recent) "history" per host
+ const highlights = [];
+ const hosts = new Set();
+ for (const page of deduped) {
+ const hostname = shortURL(page);
+ // Skip this history page if we already something from the same host
+ if (page.type === "history" && hosts.has(hostname)) {
+ continue;
+ }
+
+ // If we already have the image for the card, use that immediately. Else
+ // asynchronously fetch the image. NEVER fetch a screenshot for downloads
+ if (!page.image && page.type !== "download") {
+ this.fetchImage(page, options.isStartup);
+ }
+
+ // Adjust the type for 'history' items that are also 'bookmarked' when we
+ // want to include bookmarks
+ if (
+ page.type === "history" &&
+ page.bookmarkGuid &&
+ this.store.getState().Prefs.values[
+ "section.highlights.includeBookmarks"
+ ]
+ ) {
+ page.type = "bookmark";
+ }
+
+ // We want the page, so update various fields for UI
+ Object.assign(page, {
+ hasImage: page.type !== "download", // Downloads do not have an image - all else types fall back to a screenshot
+ hostname,
+ type: page.type,
+ pocket_id: page.pocket_id,
+ });
+
+ // Add the "bookmark", "pocket", or not-skipped "history"
+ highlights.push(page);
+ hosts.add(hostname);
+
+ // Remove internal properties that might be updated after dispatch
+ delete page.__sharedCache;
+
+ // Skip the rest if we have enough items
+ if (highlights.length === HIGHLIGHTS_MAX_LENGTH) {
+ break;
+ }
+ }
+
+ const { initialized } = this.store
+ .getState()
+ .Sections.find(section => section.id === SECTION_ID);
+ // Broadcast when required or if it is the first update.
+ const shouldBroadcast = options.broadcast || !initialized;
+
+ SectionsManager.updateSection(
+ SECTION_ID,
+ { rows: highlights },
+ shouldBroadcast,
+ options.isStartup
+ );
+ }
+
+ /**
+ * Fetch an image for a given highlight and update the card with it. If no
+ * image is available then fallback to fetching a screenshot.
+ */
+ fetchImage(page, isStartup = false) {
+ // Request a screenshot if we don't already have one pending
+ const { preview_image_url: imageUrl, url } = page;
+ return lazy.Screenshots.maybeCacheScreenshot(
+ page,
+ imageUrl || url,
+ "image",
+ image => {
+ SectionsManager.updateSectionCard(
+ SECTION_ID,
+ url,
+ { image },
+ true,
+ isStartup
+ );
+ }
+ );
+ }
+
+ onAction(action) {
+ // Relay the downloads actions to DownloadsManager - it is a child of HighlightsFeed
+ this.downloadsManager.onAction(action);
+ switch (action.type) {
+ case at.INIT:
+ this.init();
+ break;
+ case at.SYSTEM_TICK:
+ case at.TOP_SITES_UPDATED:
+ this.fetchHighlights({
+ broadcast: false,
+ isStartup: !!action.meta?.isStartup,
+ });
+ break;
+ case at.PREF_CHANGED:
+ // Update existing pages when the user changes what should be shown
+ if (action.data.name.startsWith("section.highlights.include")) {
+ this.fetchHighlights({ broadcast: true });
+ }
+ break;
+ case at.PLACES_HISTORY_CLEARED:
+ case at.PLACES_LINK_BLOCKED:
+ case at.DOWNLOAD_CHANGED:
+ case at.POCKET_LINK_DELETED_OR_ARCHIVED:
+ this.fetchHighlights({ broadcast: true });
+ break;
+ case at.PLACES_LINKS_CHANGED:
+ case at.PLACES_SAVED_TO_POCKET:
+ this.linksCache.expire();
+ this.fetchHighlights({ broadcast: false });
+ break;
+ case at.UNINIT:
+ this.uninit();
+ break;
+ }
+ }
+}
+
+const EXPORTED_SYMBOLS = [
+ "HighlightsFeed",
+ "SECTION_ID",
+ "MANY_EXTRA_LENGTH",
+ "SYNC_BOOKMARKS_FINISHED_EVENT",
+ "BOOKMARKS_RESTORE_SUCCESS_EVENT",
+ "BOOKMARKS_RESTORE_FAILED_EVENT",
+];