From 6bf0a5cb5034a7e684dcc3500e841785237ce2dd Mon Sep 17 00:00:00 2001 From: Daniel Baumann Date: Sun, 7 Apr 2024 19:32:43 +0200 Subject: Adding upstream version 1:115.7.0. Signed-off-by: Daniel Baumann --- browser/components/newtab/lib/HighlightsFeed.jsm | 350 +++++++++++++++++++++++ 1 file changed, 350 insertions(+) create mode 100644 browser/components/newtab/lib/HighlightsFeed.jsm (limited to 'browser/components/newtab/lib/HighlightsFeed.jsm') 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", +]; -- cgit v1.2.3