tor-browser

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

FrecencyBoostProvider.mjs (9097B)


      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 https://mozilla.org/MPL/2.0/. */
      4 
      5 // We use importESModule here instead of static import so that
      6 // the Karma test environment won't choke on this module. This
      7 // is because the Karma test environment already stubs out
      8 // AppConstants, and overrides importESModule to be a no-op (which
      9 // can't be done for a static import statement).
     10 
     11 // eslint-disable-next-line mozilla/use-static-import
     12 const { AppConstants } = ChromeUtils.importESModule(
     13  "resource://gre/modules/AppConstants.sys.mjs"
     14 );
     15 
     16 const lazy = {};
     17 
     18 ChromeUtils.defineESModuleGetters(lazy, {
     19  NewTabUtils: "resource://gre/modules/NewTabUtils.sys.mjs",
     20  PersistentCache: "resource://newtab/lib/PersistentCache.sys.mjs",
     21  PlacesUtils: "resource://gre/modules/PlacesUtils.sys.mjs",
     22  Region: "resource://gre/modules/Region.sys.mjs",
     23  RemoteSettings: "resource://services-settings/remote-settings.sys.mjs",
     24  Utils: "resource://services-settings/Utils.sys.mjs",
     25 });
     26 
     27 ChromeUtils.defineLazyGetter(lazy, "log", () => {
     28  const { Logger } = ChromeUtils.importESModule(
     29    "resource://messaging-system/lib/Logger.sys.mjs"
     30  );
     31  return new Logger("FrecencyBoostProvider");
     32 });
     33 
     34 ChromeUtils.defineLazyGetter(lazy, "pageFrecencyThreshold", () => {
     35  // @backward-compat { version 147 }
     36  // Frecency was changed in 147 Nightly.
     37  if (Services.vc.compare(AppConstants.MOZ_APP_VERSION, "147.0a1") >= 0) {
     38    // 30 days ago, 5 visits. The threshold avoids one non-typed visit from
     39    // immediately being included in recent history to mimic the original
     40    // threshold which aimed to prevent first-run visits from being included in
     41    // Top Sites.
     42    return lazy.PlacesUtils.history.pageFrecencyThreshold(30, 5, false);
     43  }
     44  // The old threshold used for classic frecency: Slightly over one visit.
     45  return 101;
     46 });
     47 
     48 const CACHE_KEY = "frecency_boost_cache";
     49 const RS_FALLBACK_BASE_URL =
     50  "https://firefox-settings-attachments.cdn.mozilla.net/";
     51 const SPONSORED_TILE_PARTNER_FREC_BOOST = "frec-boost";
     52 const DEFAULT_SOV_NUM_ITEMS = 200;
     53 
     54 export class FrecencyBoostProvider {
     55  constructor(frecentCache) {
     56    this.cache = new lazy.PersistentCache(CACHE_KEY, true);
     57    this.frecentCache = frecentCache;
     58    this._links = null;
     59    this._frecencyBoostedSponsors = new Map();
     60    this._frecencyBoostRS = null;
     61    this._onSync = this.onSync.bind(this);
     62  }
     63 
     64  init() {
     65    if (!this._frecencyBoostRS) {
     66      this._frecencyBoostRS = lazy.RemoteSettings(
     67        "newtab-frecency-boosted-sponsors"
     68      );
     69      this._frecencyBoostRS.on("sync", this._onSync);
     70    }
     71  }
     72 
     73  uninit() {
     74    if (this._frecencyBoostRS) {
     75      this._frecencyBoostRS.off("sync", this._onSync);
     76      this._frecencyBoostRS = null;
     77    }
     78  }
     79 
     80  async onSync() {
     81    this._frecencyBoostedSponsors = new Map();
     82    await this._importFrecencyBoostedSponsors();
     83  }
     84 
     85  /**
     86   * Import all sponsors from Remote Settings and save their favicons.
     87   * This is called lazily when frecency boosted spocs are first requested.
     88   * We fetch all favicons regardless of whether the user has visited these sites.
     89   */
     90  async _importFrecencyBoostedSponsors() {
     91    const records = await this._frecencyBoostRS?.get();
     92    if (!records) {
     93      return;
     94    }
     95 
     96    const userRegion = lazy.Region.home || "";
     97    const regionRecords = records.filter(
     98      record => record.region === userRegion
     99    );
    100 
    101    await Promise.all(
    102      regionRecords.map(record =>
    103        this._importFrecencyBoostedSponsor(record).catch(error => {
    104          lazy.log.warn(
    105            `Failed to import sponsor ${record.title || "unknown"}`,
    106            error
    107          );
    108        })
    109      )
    110    );
    111  }
    112 
    113  /**
    114   * Import a single sponsor record and fetch its favicon as data URI.
    115   *
    116   * @param {object} record - Remote Settings record with title, domain, redirect_url, and attachment
    117   */
    118  async _importFrecencyBoostedSponsor(record) {
    119    const { title, domain, redirect_url, attachment } = record;
    120    const faviconDataURI = await this._fetchSponsorFaviconAsDataURI(attachment);
    121    const hostname = lazy.NewTabUtils.shortURL({ url: domain });
    122 
    123    const sponsorData = {
    124      title,
    125      domain,
    126      hostname,
    127      redirectURL: redirect_url,
    128      faviconDataURI,
    129    };
    130 
    131    this._frecencyBoostedSponsors.set(hostname, sponsorData);
    132  }
    133 
    134  /**
    135   * Fetch favicon from Remote Settings attachment and return as data URI.
    136   *
    137   * @param {object} attachment - Remote Settings attachment object
    138   * @returns {Promise<string|null>} Favicon data URI, or null on error
    139   */
    140  async _fetchSponsorFaviconAsDataURI(attachment) {
    141    let baseAttachmentURL = RS_FALLBACK_BASE_URL;
    142    try {
    143      baseAttachmentURL = await lazy.Utils.baseAttachmentsURL();
    144    } catch (error) {
    145      lazy.log.warn(
    146        `Error fetching remote settings base url from CDN. Falling back to ${RS_FALLBACK_BASE_URL}`,
    147        error
    148      );
    149    }
    150 
    151    const faviconURL = baseAttachmentURL + attachment.location;
    152    const response = await fetch(faviconURL);
    153 
    154    const blob = await response.blob();
    155    const dataURI = await new Promise((resolve, reject) => {
    156      const reader = new FileReader();
    157      reader.addEventListener("load", () => resolve(reader.result));
    158      reader.addEventListener("error", reject);
    159      reader.readAsDataURL(blob);
    160    });
    161 
    162    return dataURI;
    163  }
    164 
    165  /**
    166   * Build frecency-boosted spocs from a list of sponsor domains by checking Places history.
    167   * Checks if domains exist in history, and returns all matches sorted by frecency.
    168   *
    169   * @param {Integer} numItems - Number of frecency items to check against.
    170   * @returns {Array} Array of sponsored tile objects sorted by frecency, or empty array
    171   */
    172  async buildFrecencyBoostedSpocs(numItems) {
    173    if (!this._frecencyBoostedSponsors.size) {
    174      return [];
    175    }
    176 
    177    const topsiteFrecency = lazy.pageFrecencyThreshold;
    178 
    179    // Get all frecent sites from history.
    180    const frecent = await this.frecentCache.request({
    181      numItems,
    182      topsiteFrecency,
    183    });
    184 
    185    const candidates = [];
    186    frecent.forEach(site => {
    187      const normalizedSiteUrl = lazy.NewTabUtils.shortURL(site);
    188      const candidate = this._frecencyBoostedSponsors.get(normalizedSiteUrl);
    189 
    190      if (
    191        candidate &&
    192        !lazy.NewTabUtils.blockedLinks.isBlocked({ url: candidate.domain })
    193      ) {
    194        candidates.push({
    195          hostname: candidate.hostname,
    196          url: candidate.redirectURL,
    197          label: candidate.title,
    198          partner: SPONSORED_TILE_PARTNER_FREC_BOOST,
    199          type: "frecency-boost",
    200          frecency: site.frecency,
    201          show_sponsored_label: true,
    202          favicon: candidate.faviconDataURI,
    203          faviconSize: 96,
    204        });
    205      }
    206    });
    207 
    208    candidates.sort((a, b) => b.frecency - a.frecency);
    209    return candidates;
    210  }
    211 
    212  async update(numItems = DEFAULT_SOV_NUM_ITEMS) {
    213    if (!this._frecencyBoostedSponsors.size) {
    214      await this._importFrecencyBoostedSponsors();
    215    }
    216 
    217    // Find all matches from the sponsor domains, sorted by frecency
    218    this._links = await this.buildFrecencyBoostedSpocs(numItems);
    219    await this.cache.set("links", this._links);
    220  }
    221 
    222  async fetch(numItems) {
    223    if (!this._links) {
    224      this._links = await this.cache.get("links");
    225 
    226      // If we still have no links we are likely in first startup.
    227      // In that case, we can fire off a background update.
    228      if (!this._links) {
    229        void this.update(numItems);
    230      }
    231    }
    232 
    233    const links = this._links || [];
    234 
    235    // Apply blocking at read time so it’s always current.
    236    return links.filter(
    237      link => !lazy.NewTabUtils.blockedLinks.isBlocked({ url: link.url })
    238    );
    239  }
    240 
    241  async retrieveRandomFrecencyTile() {
    242    if (!this._frecencyBoostedSponsors.size) {
    243      await this._importFrecencyBoostedSponsors();
    244    }
    245 
    246    const storedTile = await this.cache.get("randomFrecencyTile");
    247    if (storedTile) {
    248      const tile = JSON.parse(storedTile);
    249      if (
    250        this._frecencyBoostedSponsors.has(tile.hostname) &&
    251        !lazy.NewTabUtils.blockedLinks.isBlocked({ url: tile.url })
    252      ) {
    253        return tile;
    254      }
    255      await this.cache.set("randomFrecencyTile", null);
    256    }
    257 
    258    const candidates = Array.from(
    259      this._frecencyBoostedSponsors.values()
    260    ).filter(s => !lazy.NewTabUtils.blockedLinks.isBlocked({ url: s.domain }));
    261 
    262    if (!candidates.length) {
    263      return null;
    264    }
    265 
    266    const selected = candidates[Math.floor(Math.random() * candidates.length)];
    267    const tile = {
    268      hostname: selected.hostname,
    269      url: selected.redirectURL,
    270      label: selected.title,
    271      partner: SPONSORED_TILE_PARTNER_FREC_BOOST,
    272      type: "frecency-boost-random",
    273      show_sponsored_label: true,
    274      favicon: selected.faviconDataURI,
    275      faviconSize: 96,
    276    };
    277    await this.cache.set("randomFrecencyTile", JSON.stringify(tile));
    278    return tile;
    279  }
    280 
    281  async clearRandomFrecencyTile() {
    282    await this.cache.set("randomFrecencyTile", null);
    283  }
    284 }