tor-browser

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

PlacesFeed.sys.mjs (17620B)


      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 {
      6  actionCreators as ac,
      7  actionTypes as at,
      8  actionUtils as au,
      9 } from "resource://newtab/common/Actions.mjs";
     10 
     11 // We use importESModule here instead of static import so that
     12 // the Karma test environment won't choke on this module. This
     13 // is because the Karma test environment already stubs out
     14 // AboutNewTab, and overrides importESModule to be a no-op (which
     15 // can't be done for a static import statement).
     16 
     17 // eslint-disable-next-line mozilla/use-static-import
     18 const { AboutNewTab } = ChromeUtils.importESModule(
     19  "resource:///modules/AboutNewTab.sys.mjs"
     20 );
     21 
     22 const lazy = {};
     23 
     24 ChromeUtils.defineESModuleGetters(lazy, {
     25  BrowserUtils: "resource://gre/modules/BrowserUtils.sys.mjs",
     26  NewTabUtils: "resource://gre/modules/NewTabUtils.sys.mjs",
     27  PartnerLinkAttribution: "resource:///modules/PartnerLinkAttribution.sys.mjs",
     28  PlacesUtils: "resource://gre/modules/PlacesUtils.sys.mjs",
     29  PrivateBrowsingUtils: "resource://gre/modules/PrivateBrowsingUtils.sys.mjs",
     30 });
     31 
     32 const LINK_BLOCKED_EVENT = "newtab-linkBlocked";
     33 const PLACES_LINKS_CHANGED_DELAY_TIME = 1000; // time in ms to delay timer for places links changed events
     34 
     35 // The pref to store the blocked sponsors of the sponsored Top Sites.
     36 // The value of this pref is an array (JSON serialized) of hostnames of the
     37 // blocked sponsors.
     38 const TOP_SITES_BLOCKED_SPONSORS_PREF = "browser.topsites.blockedSponsors";
     39 
     40 const PREF_UNIFIED_ADS_TILES_ENABLED =
     41  "browser.newtabpage.activity-stream.unifiedAds.tiles.enabled";
     42 
     43 const PREF_UNIFIED_ADS_BLOCKED_LIST =
     44  "browser.newtabpage.activity-stream.unifiedAds.blockedAds";
     45 
     46 /**
     47 * PlacesObserver - observes events from PlacesUtils.observers
     48 */
     49 class PlacesObserver {
     50  constructor(dispatch) {
     51    this.dispatch = dispatch;
     52    this.QueryInterface = ChromeUtils.generateQI(["nsISupportsWeakReference"]);
     53    this.handlePlacesEvent = this.handlePlacesEvent.bind(this);
     54  }
     55 
     56  handlePlacesEvent(events) {
     57    const removedPages = [];
     58    const removedBookmarks = [];
     59 
     60    for (const {
     61      itemType,
     62      source,
     63      dateAdded,
     64      guid,
     65      title,
     66      url,
     67      isRemovedFromStore,
     68      isTagging,
     69      type,
     70    } of events) {
     71      switch (type) {
     72        case "history-cleared":
     73          this.dispatch({ type: at.PLACES_HISTORY_CLEARED });
     74          break;
     75        case "page-removed":
     76          if (isRemovedFromStore) {
     77            removedPages.push(url);
     78          }
     79          break;
     80        case "bookmark-added":
     81          // Skips items that are not bookmarks (like folders), about:* pages or
     82          // default bookmarks, added when the profile is created.
     83          if (
     84            isTagging ||
     85            itemType !== lazy.PlacesUtils.bookmarks.TYPE_BOOKMARK ||
     86            source === lazy.PlacesUtils.bookmarks.SOURCES.IMPORT ||
     87            source === lazy.PlacesUtils.bookmarks.SOURCES.RESTORE ||
     88            source === lazy.PlacesUtils.bookmarks.SOURCES.RESTORE_ON_STARTUP ||
     89            source === lazy.PlacesUtils.bookmarks.SOURCES.SYNC ||
     90            (!url.startsWith("http://") && !url.startsWith("https://"))
     91          ) {
     92            return;
     93          }
     94 
     95          this.dispatch({ type: at.PLACES_LINKS_CHANGED });
     96          this.dispatch({
     97            type: at.PLACES_BOOKMARK_ADDED,
     98            data: {
     99              bookmarkGuid: guid,
    100              bookmarkTitle: title,
    101              dateAdded: dateAdded * 1000,
    102              url,
    103            },
    104          });
    105          break;
    106        case "bookmark-removed":
    107          if (
    108            isTagging ||
    109            (itemType === lazy.PlacesUtils.bookmarks.TYPE_BOOKMARK &&
    110              source !== lazy.PlacesUtils.bookmarks.SOURCES.IMPORT &&
    111              source !== lazy.PlacesUtils.bookmarks.SOURCES.RESTORE &&
    112              source !==
    113                lazy.PlacesUtils.bookmarks.SOURCES.RESTORE_ON_STARTUP &&
    114              source !== lazy.PlacesUtils.bookmarks.SOURCES.SYNC)
    115          ) {
    116            removedBookmarks.push(url);
    117          }
    118          break;
    119      }
    120    }
    121 
    122    if (removedPages.length || removedBookmarks.length) {
    123      this.dispatch({ type: at.PLACES_LINKS_CHANGED });
    124    }
    125 
    126    if (removedPages.length) {
    127      this.dispatch({
    128        type: at.PLACES_LINKS_DELETED,
    129        data: { urls: removedPages },
    130      });
    131    }
    132 
    133    if (removedBookmarks.length) {
    134      this.dispatch({
    135        type: at.PLACES_BOOKMARKS_REMOVED,
    136        data: { urls: removedBookmarks },
    137      });
    138    }
    139  }
    140 }
    141 
    142 export class PlacesFeed {
    143  constructor() {
    144    this.placesChangedTimer = null;
    145    this.customDispatch = this.customDispatch.bind(this);
    146    this.placesObserver = new PlacesObserver(this.customDispatch);
    147  }
    148 
    149  addObservers() {
    150    lazy.PlacesUtils.observers.addListener(
    151      ["bookmark-added", "bookmark-removed", "history-cleared", "page-removed"],
    152      this.placesObserver.handlePlacesEvent
    153    );
    154 
    155    Services.obs.addObserver(this, LINK_BLOCKED_EVENT);
    156  }
    157 
    158  /**
    159   * setTimeout - A custom function that creates an nsITimer that can be cancelled
    160   *
    161   * @param {func} callback       A function to be executed after the timer expires
    162   * @param {int}  delay          The time (in ms) the timer should wait before the function is executed
    163   */
    164  setTimeout(callback, delay) {
    165    let timer = Cc["@mozilla.org/timer;1"].createInstance(Ci.nsITimer);
    166    timer.initWithCallback(callback, delay, Ci.nsITimer.TYPE_ONE_SHOT);
    167    return timer;
    168  }
    169 
    170  customDispatch(action) {
    171    // If we are changing many links at once, delay this action and only dispatch
    172    // one action at the end
    173    if (action.type === at.PLACES_LINKS_CHANGED) {
    174      if (this.placesChangedTimer) {
    175        this.placesChangedTimer.delay = PLACES_LINKS_CHANGED_DELAY_TIME;
    176      } else {
    177        this.placesChangedTimer = this.setTimeout(() => {
    178          this.placesChangedTimer = null;
    179          this.store.dispatch(ac.OnlyToMain(action));
    180        }, PLACES_LINKS_CHANGED_DELAY_TIME);
    181      }
    182    } else {
    183      // To avoid blocking Places notifications on expensive work, run it at the
    184      // next tick of the events loop.
    185      Services.tm.dispatchToMainThread(() =>
    186        this.store.dispatch(ac.BroadcastToContent(action))
    187      );
    188    }
    189  }
    190 
    191  removeObservers() {
    192    if (this.placesChangedTimer) {
    193      this.placesChangedTimer.cancel();
    194      this.placesChangedTimer = null;
    195    }
    196    lazy.PlacesUtils.observers.removeListener(
    197      ["bookmark-added", "bookmark-removed", "history-cleared", "page-removed"],
    198      this.placesObserver.handlePlacesEvent
    199    );
    200    Services.obs.removeObserver(this, LINK_BLOCKED_EVENT);
    201  }
    202 
    203  /**
    204   * observe - An observer for the LINK_BLOCKED_EVENT.
    205   *           Called when a link is blocked.
    206   *           Links can be blocked outside of newtab,
    207   *           which is why we need to listen to this
    208   *           on such a generic level.
    209   *
    210   * @param  {null} subject
    211   * @param  {str} topic   The name of the event
    212   * @param  {str} value   The data associated with the event
    213   */
    214  observe(subject, topic, value) {
    215    if (topic === LINK_BLOCKED_EVENT) {
    216      this.store.dispatch(
    217        ac.BroadcastToContent({
    218          type: at.PLACES_LINK_BLOCKED,
    219          data: { url: value },
    220        })
    221      );
    222    }
    223  }
    224 
    225  /**
    226   * Open a link in a desired destination defaulting to action's event.
    227   */
    228  openLink(action, where = "", isPrivate = false) {
    229    const params = {
    230      private: isPrivate,
    231      targetBrowser: action._target.browser,
    232      forceForeground: false, // This ensure we maintain user preference for how to open new tabs.
    233      globalHistoryOptions: {
    234        triggeringSponsoredURL: action.data.is_sponsored
    235          ? action.data.url
    236          : undefined,
    237        triggeringSource: "newtab",
    238      },
    239    };
    240 
    241    // Always include the referrer (even for http links) if we have one
    242    const { event, referrer, typedBonus } = action.data;
    243    if (referrer) {
    244      const ReferrerInfo = Components.Constructor(
    245        "@mozilla.org/referrer-info;1",
    246        "nsIReferrerInfo",
    247        "init"
    248      );
    249      params.referrerInfo = new ReferrerInfo(
    250        Ci.nsIReferrerInfo.UNSAFE_URL,
    251        true,
    252        Services.io.newURI(referrer)
    253      );
    254    }
    255 
    256    // Pocket gives us a special reader URL to open their stories in
    257    const urlToOpen =
    258      action.data.type === "pocket" ? action.data.open_url : action.data.url;
    259 
    260    try {
    261      let uri = Services.io.newURI(urlToOpen);
    262      if (!["http", "https"].includes(uri.scheme)) {
    263        throw new Error(
    264          `Can't open link using ${uri.scheme} protocol from the new tab page.`
    265        );
    266      }
    267    } catch (e) {
    268      console.error(e);
    269      return;
    270    }
    271 
    272    // Mark the page as typed for frecency bonus before opening the link
    273    if (typedBonus) {
    274      lazy.PlacesUtils.history.markPageAsTyped(Services.io.newURI(urlToOpen));
    275    }
    276 
    277    const win = action._target.browser.ownerGlobal;
    278    win.openTrustedLinkIn(
    279      urlToOpen,
    280      where || lazy.BrowserUtils.whereToOpenLink(event),
    281      params
    282    );
    283 
    284    // If there's an original URL e.g. using the unprocessed %YYYYMMDDHH% tag,
    285    // add a visit for that so it may become a frecent top site.
    286    if (action.data.original_url) {
    287      lazy.PlacesUtils.history.insert({
    288        url: action.data.original_url,
    289        visits: [{ transition: lazy.PlacesUtils.history.TRANSITION_TYPED }],
    290      });
    291    }
    292  }
    293 
    294  /**
    295   * Sends an attribution request for Top Sites interactions.
    296   *
    297   * @param {object} data
    298   *   Attribution paramters from a Top Site.
    299   */
    300  makeAttributionRequest(data) {
    301    let args = Object.assign(
    302      {
    303        campaignID: Services.prefs.getStringPref(
    304          "browser.partnerlink.campaign.topsites"
    305        ),
    306      },
    307      data
    308    );
    309    lazy.PartnerLinkAttribution.makeRequest(args);
    310  }
    311 
    312  async fillSearchTopSiteTerm({ _target, data }) {
    313    const searchEngine = await Services.search.getEngineByAlias(data.label);
    314    _target.browser.ownerGlobal.gURLBar.search(data.label, {
    315      searchEngine,
    316      searchModeEntry: "topsites_newtab",
    317    });
    318  }
    319 
    320  _getDefaultSearchEngine(isPrivateWindow) {
    321    return Services.search[
    322      isPrivateWindow ? "defaultPrivateEngine" : "defaultEngine"
    323    ];
    324  }
    325 
    326  /**
    327   * @backward-compat { version 148 }
    328   *
    329   * This, and all newtab-specific handoff searchbar handling can be removed
    330   * once 147 is released, as all handoff UI and logic will be handled by
    331   * contentSearchHandoffUI and the ContentSearch JSWindowActors.
    332   */
    333  handoffSearchToAwesomebar(action) {
    334    const { _target, data, meta } = action;
    335    const searchEngine = this._getDefaultSearchEngine(
    336      lazy.PrivateBrowsingUtils.isBrowserPrivate(_target.browser)
    337    );
    338    const urlBar = _target.browser.ownerGlobal.gURLBar;
    339    let isFirstChange = true;
    340 
    341    const newtabSession = AboutNewTab.activityStream.store.feeds
    342      .get("feeds.telemetry")
    343      ?.sessions.get(au.getPortIdOfSender(action));
    344    if (!data || !data.text) {
    345      urlBar.setHiddenFocus();
    346    } else {
    347      urlBar.handoff(data.text, searchEngine, newtabSession?.session_id);
    348      isFirstChange = false;
    349    }
    350 
    351    const checkFirstChange = () => {
    352      // Check if this is the first change since we hidden focused. If it is,
    353      // remove hidden focus styles, prepend the search alias and hide the
    354      // in-content search.
    355      if (isFirstChange) {
    356        isFirstChange = false;
    357        urlBar.removeHiddenFocus(true);
    358        urlBar.handoff("", searchEngine, newtabSession?.session_id);
    359        this.store.dispatch(
    360          ac.OnlyToOneContent({ type: at.DISABLE_SEARCH }, meta.fromTarget)
    361        );
    362        urlBar.removeEventListener("compositionstart", checkFirstChange);
    363        urlBar.removeEventListener("paste", checkFirstChange);
    364      }
    365    };
    366 
    367    const onKeydown = ev => {
    368      // Check if the keydown will cause a value change.
    369      if (ev.key.length === 1 && !ev.altKey && !ev.ctrlKey && !ev.metaKey) {
    370        checkFirstChange();
    371      }
    372      // If the Esc button is pressed, we are done. Show in-content search and cleanup.
    373      if (ev.key === "Escape") {
    374        onDone(); // eslint-disable-line no-use-before-define
    375      }
    376    };
    377 
    378    const onDone = ev => {
    379      // We are done. Show in-content search again and cleanup.
    380      this.store.dispatch(
    381        ac.OnlyToOneContent({ type: at.SHOW_SEARCH }, meta.fromTarget)
    382      );
    383 
    384      const forceSuppressFocusBorder = ev?.type === "mousedown";
    385      urlBar.removeHiddenFocus(forceSuppressFocusBorder);
    386 
    387      urlBar.removeEventListener("keydown", onKeydown);
    388      urlBar.removeEventListener("mousedown", onDone);
    389      urlBar.removeEventListener("blur", onDone);
    390      urlBar.removeEventListener("compositionstart", checkFirstChange);
    391      urlBar.removeEventListener("paste", checkFirstChange);
    392    };
    393 
    394    urlBar.addEventListener("keydown", onKeydown);
    395    urlBar.addEventListener("mousedown", onDone);
    396    urlBar.addEventListener("blur", onDone);
    397    urlBar.addEventListener("compositionstart", checkFirstChange);
    398    urlBar.addEventListener("paste", checkFirstChange);
    399  }
    400 
    401  /**
    402   * Add the hostnames of the given urls to the Top Sites sponsor blocklist.
    403   *
    404   * @param {Array} urls
    405   *   An array of the objects structured as `{ url }`
    406   */
    407  addToBlockedTopSitesSponsors(urls) {
    408    const blockedPref = JSON.parse(
    409      Services.prefs.getStringPref(TOP_SITES_BLOCKED_SPONSORS_PREF, "[]")
    410    );
    411    const merged = new Set([
    412      ...blockedPref,
    413      ...urls.map(url => lazy.NewTabUtils.shortURL(url)),
    414    ]);
    415 
    416    Services.prefs.setStringPref(
    417      TOP_SITES_BLOCKED_SPONSORS_PREF,
    418      JSON.stringify([...merged])
    419    );
    420  }
    421 
    422  /**
    423   * Add the block key (uuid) of the given urls to the blocked ads pref
    424   * to send back to the ads service when requesting new topsite ads
    425   * from the unified ads service
    426   *
    427   * @param {Array} block_key
    428   *   An array of the (string) keys
    429   */
    430  addToUnifiedAdsBlockedAdsList(keysArray) {
    431    const blockedAdsPref = Services.prefs.getStringPref(
    432      PREF_UNIFIED_ADS_BLOCKED_LIST,
    433      ""
    434    );
    435 
    436    let blockedAdsArray;
    437 
    438    if (blockedAdsPref === "") {
    439      // Set new IDs as prev blocked array
    440      blockedAdsArray = keysArray;
    441    } else {
    442      // Convert prev blocked csv list to array
    443      blockedAdsArray = blockedAdsPref
    444        .split(",")
    445        .map(s => s.trim())
    446        .filter(item => item);
    447      // Add new IDs to prev blocked array
    448      blockedAdsArray = blockedAdsArray.concat(keysArray);
    449    }
    450 
    451    // Save generated array as a CSV string
    452    Services.prefs.setStringPref(
    453      PREF_UNIFIED_ADS_BLOCKED_LIST,
    454      blockedAdsArray.join(",")
    455    );
    456  }
    457 
    458  onAction(action) {
    459    const unifiedAdsTilesEnabled = Services.prefs.getBoolPref(
    460      PREF_UNIFIED_ADS_TILES_ENABLED,
    461      false
    462    );
    463 
    464    switch (action.type) {
    465      case at.INIT:
    466        // Briefly avoid loading services for observing for better startup timing
    467        Services.tm.dispatchToMainThread(() => this.addObservers());
    468        break;
    469      case at.UNINIT:
    470        this.removeObservers();
    471        break;
    472      case at.ABOUT_SPONSORED_TOP_SITES: {
    473        const url = `${Services.urlFormatter.formatURLPref(
    474          "app.support.baseURL"
    475        )}sponsor-privacy`;
    476        const win = action._target.browser.ownerGlobal;
    477        win.openTrustedLinkIn(url, "tab");
    478        break;
    479      }
    480      case at.BLOCK_URL: {
    481        if (action.data) {
    482          let sponsoredTopSites = [];
    483          let sponsoredBlockKeys = [];
    484          action.data.forEach(site => {
    485            const { url, pocket_id, isSponsoredTopSite, block_key } = site;
    486            lazy.NewTabUtils.activityStreamLinks.blockURL({ url, pocket_id });
    487 
    488            if (isSponsoredTopSite) {
    489              sponsoredTopSites.push({ url });
    490 
    491              // Add block keys if available
    492              if (unifiedAdsTilesEnabled) {
    493                sponsoredBlockKeys.push(block_key);
    494              }
    495            }
    496          });
    497          if (sponsoredTopSites.length) {
    498            this.addToBlockedTopSitesSponsors(sponsoredTopSites);
    499          }
    500          if (sponsoredBlockKeys.length) {
    501            this.addToUnifiedAdsBlockedAdsList(sponsoredBlockKeys);
    502          }
    503        }
    504        break;
    505      }
    506      case at.BOOKMARK_URL:
    507        lazy.NewTabUtils.activityStreamLinks.addBookmark(
    508          action.data,
    509          action._target.browser.ownerGlobal
    510        );
    511        break;
    512      case at.DELETE_BOOKMARK_BY_ID:
    513        lazy.NewTabUtils.activityStreamLinks.deleteBookmark(action.data);
    514        break;
    515      case at.DELETE_HISTORY_URL: {
    516        const { url, forceBlock, pocket_id } = action.data;
    517        lazy.NewTabUtils.activityStreamLinks.deleteHistoryEntry(url);
    518        if (forceBlock) {
    519          lazy.NewTabUtils.activityStreamLinks.blockURL({ url, pocket_id });
    520        }
    521        break;
    522      }
    523      case at.OPEN_NEW_WINDOW:
    524        this.openLink(action, "window");
    525        break;
    526      case at.OPEN_PRIVATE_WINDOW:
    527        this.openLink(action, "window", true);
    528        break;
    529      case at.FILL_SEARCH_TERM:
    530        this.fillSearchTopSiteTerm(action);
    531        break;
    532      case at.HANDOFF_SEARCH_TO_AWESOMEBAR:
    533        this.handoffSearchToAwesomebar(action);
    534        break;
    535      case at.OPEN_LINK: {
    536        this.openLink(action);
    537        break;
    538      }
    539      case at.PARTNER_LINK_ATTRIBUTION:
    540        this.makeAttributionRequest(action.data);
    541        break;
    542    }
    543  }
    544 }
    545 
    546 // Exported for testing only
    547 PlacesFeed.PlacesObserver = PlacesObserver;