tor-browser

The Tor Browser
git clone https://git.dasho.dev/tor-browser.git
Log | Files | Refs | README | LICENSE

HighlightsFeed.sys.mjs (10503B)


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