browser/components/places/PlacesUIUtils.jsm
author Gijs Kruitbosch <gijskruitbosch@gmail.com>
Thu, 25 Oct 2018 10:52:59 +0000
changeset 491307 25c9834781deba0c15438e8b3583b2f2c77a2124
parent 484543 4335c690a98c5ed7d8b892ed8fa59aa64fc11d0c
permissions -rw-r--r--
Bug 1501592 - remove remaining expired feed telemetry, r=mak Differential Revision: https://phabricator.services.mozilla.com/D9665

/* -*- indent-tabs-mode: nil; js-indent-level: 2 -*- */
/* 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/. */

var EXPORTED_SYMBOLS = ["PlacesUIUtils"];

ChromeUtils.import("resource://gre/modules/XPCOMUtils.jsm");
ChromeUtils.import("resource://gre/modules/Services.jsm");
ChromeUtils.import("resource://gre/modules/Timer.jsm");

XPCOMUtils.defineLazyGlobalGetters(this, ["Element"]);

XPCOMUtils.defineLazyModuleGetters(this, {
  AppConstants: "resource://gre/modules/AppConstants.jsm",
  BrowserWindowTracker: "resource:///modules/BrowserWindowTracker.jsm",
  OpenInTabsUtils: "resource:///modules/OpenInTabsUtils.jsm",
  PlacesTransactions: "resource://gre/modules/PlacesTransactions.jsm",
  PlacesUtils: "resource://gre/modules/PlacesUtils.jsm",
  PluralForm: "resource://gre/modules/PluralForm.jsm",
  PrivateBrowsingUtils: "resource://gre/modules/PrivateBrowsingUtils.jsm",
  PromiseUtils: "resource://gre/modules/PromiseUtils.jsm",
  Weave: "resource://services-sync/main.js",
});

XPCOMUtils.defineLazyGetter(this, "bundle", function() {
  return Services.strings.createBundle("chrome://browser/locale/places/places.properties");
});

const gInContentProcess = Services.appinfo.processType == Ci.nsIXULRuntime.PROCESS_TYPE_CONTENT;
const FAVICON_REQUEST_TIMEOUT = 60 * 1000;
// Map from windows to arrays of data about pending favicon loads.
let gFaviconLoadDataMap = new Map();

const ITEM_CHANGED_BATCH_NOTIFICATION_THRESHOLD = 10;

// copied from utilityOverlay.js
const TAB_DROP_TYPE = "application/x-moz-tabbrowser-tab";
const PREF_LOAD_BOOKMARKS_IN_BACKGROUND = "browser.tabs.loadBookmarksInBackground";
const PREF_LOAD_BOOKMARKS_IN_TABS = "browser.tabs.loadBookmarksInTabs";

let InternalFaviconLoader = {
  /**
   * This gets called for every inner window that is destroyed.
   * In the parent process, we process the destruction ourselves. In the child process,
   * we notify the parent which will then process it based on that message.
   */
  observe(subject, topic, data) {
    let innerWindowID = subject.QueryInterface(Ci.nsISupportsPRUint64).data;
    this.removeRequestsForInner(innerWindowID);
  },

  /**
   * Actually cancel the request, and clear the timeout for cancelling it.
   */
  _cancelRequest({uri, innerWindowID, timerID, callback}, reason) {
    // Break cycle
    let request = callback.request;
    delete callback.request;
    // Ensure we don't time out.
    clearTimeout(timerID);
    try {
      request.cancel();
    } catch (ex) {
      Cu.reportError("When cancelling a request for " + uri.spec + " because " + reason + ", it was already canceled!");
    }
  },

  /**
   * Called for every inner that gets destroyed, only in the parent process.
   */
  removeRequestsForInner(innerID) {
    for (let [window, loadDataForWindow] of gFaviconLoadDataMap) {
      let newLoadDataForWindow = loadDataForWindow.filter(loadData => {
        let innerWasDestroyed = loadData.innerWindowID == innerID;
        if (innerWasDestroyed) {
          this._cancelRequest(loadData, "the inner window was destroyed or a new favicon was loaded for it");
        }
        // Keep the items whose inner is still alive.
        return !innerWasDestroyed;
      });
      // Map iteration with for...of is safe against modification, so
      // now just replace the old value:
      gFaviconLoadDataMap.set(window, newLoadDataForWindow);
    }
  },

  /**
   * Called when a toplevel chrome window unloads. We use this to tidy up after ourselves,
   * avoid leaks, and cancel any remaining requests. The last part should in theory be
   * handled by the inner-window-destroyed handlers. We clean up just to be on the safe side.
   */
  onUnload(win) {
    let loadDataForWindow = gFaviconLoadDataMap.get(win);
    if (loadDataForWindow) {
      for (let loadData of loadDataForWindow) {
        this._cancelRequest(loadData, "the chrome window went away");
      }
    }
    gFaviconLoadDataMap.delete(win);
  },

  /**
   * Remove a particular favicon load's loading data from our map tracking
   * load data per chrome window.
   *
   * @param win
   *        the chrome window in which we should look for this load
   * @param filterData ({innerWindowID, uri, callback})
   *        the data we should use to find this particular load to remove.
   *
   * @return the loadData object we removed, or null if we didn't find any.
   */
  _removeLoadDataFromWindowMap(win, {innerWindowID, uri, callback}) {
    let loadDataForWindow = gFaviconLoadDataMap.get(win);
    if (loadDataForWindow) {
      let itemIndex = loadDataForWindow.findIndex(loadData => {
        return loadData.innerWindowID == innerWindowID &&
               loadData.uri.equals(uri) &&
               loadData.callback.request == callback.request;
      });
      if (itemIndex != -1) {
        let loadData = loadDataForWindow[itemIndex];
        loadDataForWindow.splice(itemIndex, 1);
        return loadData;
      }
    }
    return null;
  },

  /**
   * Create a function to use as a nsIFaviconDataCallback, so we can remove cancelling
   * information when the request succeeds. Note that right now there are some edge-cases,
   * such as about: URIs with chrome:// favicons where the success callback is not invoked.
   * This is OK: we will 'cancel' the request after the timeout (or when the window goes
   * away) but that will be a no-op in such cases.
   */
  _makeCompletionCallback(win, id) {
    return {
      onComplete(uri) {
        let loadData = InternalFaviconLoader._removeLoadDataFromWindowMap(win, {
          uri,
          innerWindowID: id,
          callback: this,
        });
        if (loadData) {
          clearTimeout(loadData.timerID);
        }
        delete this.request;
      },
    };
  },

  ensureInitialized() {
    if (this._initialized) {
      return;
    }
    this._initialized = true;

    Services.obs.addObserver(this, "inner-window-destroyed");
    Services.ppmm.addMessageListener("Toolkit:inner-window-destroyed", msg => {
      this.removeRequestsForInner(msg.data);
    });
  },

  loadFavicon(browser, principal, pageURI, uri, expiration, iconURI) {
    this.ensureInitialized();
    let {ownerGlobal: win, innerWindowID} = browser;
    if (!gFaviconLoadDataMap.has(win)) {
      gFaviconLoadDataMap.set(win, []);
      let unloadHandler = event => {
        let doc = event.target;
        let eventWin = doc.defaultView;
        if (eventWin == win) {
          win.removeEventListener("unload", unloadHandler);
          this.onUnload(win);
        }
      };
      win.addEventListener("unload", unloadHandler, true);
    }

    // First we do the actual setAndFetch call:
    let loadType = PrivateBrowsingUtils.isWindowPrivate(win)
      ? PlacesUtils.favicons.FAVICON_LOAD_PRIVATE
      : PlacesUtils.favicons.FAVICON_LOAD_NON_PRIVATE;
    let callback = this._makeCompletionCallback(win, innerWindowID);

    if (iconURI && iconURI.schemeIs("data")) {
      expiration = PlacesUtils.toPRTime(expiration);
      PlacesUtils.favicons.replaceFaviconDataFromDataURL(uri, iconURI.spec,
                                                         expiration, principal);
    }

    let request = PlacesUtils.favicons.setAndFetchFaviconForPage(
      pageURI, uri, false, loadType, callback, principal);

    // Now register the result so we can cancel it if/when necessary.
    if (!request) {
      // The favicon service can return with success but no-op (and leave request
      // as null) if the icon is the same as the page (e.g. for images) or if it is
      // the favicon for an error page. In this case, we do not need to do anything else.
      return;
    }
    callback.request = request;
    let loadData = {innerWindowID, uri, callback};
    loadData.timerID = setTimeout(() => {
      this._cancelRequest(loadData, "it timed out");
      this._removeLoadDataFromWindowMap(win, loadData);
    }, FAVICON_REQUEST_TIMEOUT);
    let loadDataForWindow = gFaviconLoadDataMap.get(win);
    loadDataForWindow.push(loadData);
  },
};

var PlacesUIUtils = {
  LAST_USED_FOLDERS_META_KEY: "bookmarks/lastusedfolders",

  /**
   * Makes a URI from a spec, and do fixup
   * @param   aSpec
   *          The string spec of the URI
   * @return A URI object for the spec.
   */
  createFixedURI: function PUIU_createFixedURI(aSpec) {
    return Services.uriFixup.createFixupURI(aSpec, Ci.nsIURIFixup.FIXUP_FLAG_NONE);
  },

  getFormattedString: function PUIU_getFormattedString(key, params) {
    return bundle.formatStringFromName(key, params, params.length);
  },

  /**
   * Get a localized plural string for the specified key name and numeric value
   * substituting parameters.
   *
   * @param   aKey
   *          String, key for looking up the localized string in the bundle
   * @param   aNumber
   *          Number based on which the final localized form is looked up
   * @param   aParams
   *          Array whose items will substitute #1, #2,... #n parameters
   *          in the string.
   *
   * @see https://developer.mozilla.org/en/Localization_and_Plurals
   * @return The localized plural string.
   */
  getPluralString: function PUIU_getPluralString(aKey, aNumber, aParams) {
    let str = PluralForm.get(aNumber, bundle.GetStringFromName(aKey));

    // Replace #1 with aParams[0], #2 with aParams[1], and so on.
    return str.replace(/\#(\d+)/g, function(matchedId, matchedNumber) {
      let param = aParams[parseInt(matchedNumber, 10) - 1];
      return param !== undefined ? param : matchedId;
    });
  },

  getString: function PUIU_getString(key) {
    return bundle.GetStringFromName(key);
  },

  /**
   * Shows the bookmark dialog corresponding to the specified info.
   *
   * @param aInfo
   *        Describes the item to be edited/added in the dialog.
   *        See documentation at the top of bookmarkProperties.js
   * @param aWindow
   *        Owner window for the new dialog.
   *
   * @see documentation at the top of bookmarkProperties.js
   * @return The guid of the item that was created or edited, undefined otherwise.
   */
  showBookmarkDialog(aInfo, aParentWindow) {
    // Preserve size attributes differently based on the fact the dialog has
    // a folder picker or not, since it needs more horizontal space than the
    // other controls.
    let hasFolderPicker = !("hiddenRows" in aInfo) ||
                          !aInfo.hiddenRows.includes("folderPicker");
    // Use a different chrome url to persist different sizes.
    let dialogURL = hasFolderPicker ?
                    "chrome://browser/content/places/bookmarkProperties2.xul" :
                    "chrome://browser/content/places/bookmarkProperties.xul";

    let features = "centerscreen,chrome,modal,resizable=yes";

    let topUndoEntry;
    let batchBlockingDeferred;

    // Set the transaction manager into batching mode.
    topUndoEntry = PlacesTransactions.topUndoEntry;
    batchBlockingDeferred = PromiseUtils.defer();
    PlacesTransactions.batch(async () => {
      await batchBlockingDeferred.promise;
    });

    aParentWindow.openDialog(dialogURL, "", features, aInfo);

    let bookmarkGuid = ("bookmarkGuid" in aInfo && aInfo.bookmarkGuid) || undefined;

    batchBlockingDeferred.resolve();

    if (!bookmarkGuid &&
        topUndoEntry != PlacesTransactions.topUndoEntry) {
      PlacesTransactions.undo().catch(Cu.reportError);
    }

    return bookmarkGuid;
  },

  /**
   * set and fetch a favicon. Can only be used from the parent process.
   * @param browser    {Browser}   The XUL browser element for which we're fetching a favicon.
   * @param principal  {Principal} The loading principal to use for the fetch.
   * @pram pageURI     {URI}       The page URI associated to this favicon load.
   * @param uri        {URI}       The URI to fetch.
   * @param expiration {Number}    An optional expiration time.
   * @param iconURI    {URI}       An optional data: URI holding the icon's data.
   */
  loadFavicon(browser, principal, pageURI, uri, expiration = 0, iconURI = null) {
    if (gInContentProcess) {
      throw new Error("Can't track loads from within the child process!");
    }
    InternalFaviconLoader.loadFavicon(browser, principal, pageURI, uri, expiration, iconURI);
  },

  /**
   * Returns the closet ancestor places view for the given DOM node
   * @param aNode
   *        a DOM node
   * @return the closet ancestor places view if exists, null otherwsie.
   */
  getViewForNode: function PUIU_getViewForNode(aNode) {
    let node = aNode;

    if (node.localName == "panelview" && node._placesView) {
      return node._placesView;
    }

    // The view for a <menu> of which its associated menupopup is a places
    // view, is the menupopup.
    if (node.localName == "menu" && !node._placesNode &&
        node.lastChild._placesView)
      return node.lastChild._placesView;

    while (Element.isInstance(node)) {
      if (node._placesView)
        return node._placesView;
      if (node.localName == "tree" && node.getAttribute("type") == "places")
        return node;

      node = node.parentNode;
    }

    return null;
  },

  /**
   * Returns the active PlacesController for a given command.
   *
   * @param win The window containing the affected view
   * @param command The command
   * @return a PlacesController
   */
  getControllerForCommand(win, command) {
    // A context menu may be built for non-focusable views.  Thus, we first try
    // to look for a view associated with document.popupNode
    let popupNode;
    try {
      popupNode = win.document.popupNode;
    } catch (e) {
      // The document went away (bug 797307).
      return null;
    }
    if (popupNode) {
      let view = this.getViewForNode(popupNode);
      if (view && view._contextMenuShown)
        return view.controllers.getControllerForCommand(command);
    }

    // When we're not building a context menu, only focusable views
    // are possible.  Thus, we can safely use the command dispatcher.
    let controller = win.top.document.commandDispatcher
                        .getControllerForCommand(command);
    return controller || null;
  },

  /**
   * Update all the Places commands for the given window.
   *
   * @param win The window to update.
   */
  updateCommands(win) {
    // Get the controller for one of the places commands.
    let controller = this.getControllerForCommand(win, "placesCmd_open");
    for (let command of [
      "placesCmd_open",
      "placesCmd_open:window",
      "placesCmd_open:privatewindow",
      "placesCmd_open:tab",
      "placesCmd_new:folder",
      "placesCmd_new:bookmark",
      "placesCmd_new:separator",
      "placesCmd_show:info",
      "placesCmd_reload",
      "placesCmd_sortBy:name",
      "placesCmd_cut",
      "placesCmd_copy",
      "placesCmd_paste",
      "placesCmd_delete",
    ]) {
      win.goSetCommandEnabled(command,
                              controller && controller.isCommandEnabled(command));
    }
  },

  /**
   * Executes the given command on the currently active controller.
   *
   * @param win The window containing the affected view
   * @param command The command to execute
   */
  doCommand(win, command) {
    let controller = this.getControllerForCommand(win, command);
    if (controller && controller.isCommandEnabled(command))
      controller.doCommand(command);
  },

  /**
   * By calling this before visiting an URL, the visit will be associated to a
   * TRANSITION_TYPED transition (if there is no a referrer).
   * This is used when visiting pages from the history menu, history sidebar,
   * url bar, url autocomplete results, and history searches from the places
   * organizer.  If this is not called visits will be marked as
   * TRANSITION_LINK.
   */
  markPageAsTyped: function PUIU_markPageAsTyped(aURL) {
    PlacesUtils.history.markPageAsTyped(this.createFixedURI(aURL));
  },

  /**
   * By calling this before visiting an URL, the visit will be associated to a
   * TRANSITION_BOOKMARK transition.
   * This is used when visiting pages from the bookmarks menu,
   * personal toolbar, and bookmarks from within the places organizer.
   * If this is not called visits will be marked as TRANSITION_LINK.
   */
  markPageAsFollowedBookmark: function PUIU_markPageAsFollowedBookmark(aURL) {
    PlacesUtils.history.markPageAsFollowedBookmark(this.createFixedURI(aURL));
  },

  /**
   * By calling this before visiting an URL, any visit in frames will be
   * associated to a TRANSITION_FRAMED_LINK transition.
   * This is actually used to distinguish user-initiated visits in frames
   * so automatic visits can be correctly ignored.
   */
  markPageAsFollowedLink: function PUIU_markPageAsFollowedLink(aURL) {
    PlacesUtils.history.markPageAsFollowedLink(this.createFixedURI(aURL));
  },

  /**
   * Sets the character-set for a page. The character set will not be saved
   * if the window is determined to be a private browsing window.
   *
   * @param {string|URL|nsIURI} url The URL of the page to set the charset on.
   * @param {String} charset character-set value.
   * @param {window} window The window that the charset is being set from.
   * @return {Promise}
   */
  async setCharsetForPage(url, charset, window) {
    if (PrivateBrowsingUtils.isWindowPrivate(window)) {
      return;
    }

    // UTF-8 is the default. If we are passed the value then set it to null,
    // to ensure any charset is removed from the database.
    if (charset.toLowerCase() == "utf-8") {
      charset = null;
    }

    await PlacesUtils.history.update({
      url,
      annotations: new Map([[PlacesUtils.CHARSET_ANNO, charset]]),
    });
  },

  /**
   * Allows opening of javascript/data URI only if the given node is
   * bookmarked (see bug 224521).
   * @param aURINode
   *        a URI node
   * @param aWindow
   *        a window on which a potential error alert is shown on.
   * @return true if it's safe to open the node in the browser, false otherwise.
   *
   */
  checkURLSecurity: function PUIU_checkURLSecurity(aURINode, aWindow) {
    if (PlacesUtils.nodeIsBookmark(aURINode))
      return true;

    var uri = Services.io.newURI(aURINode.uri);
    if (uri.schemeIs("javascript") || uri.schemeIs("data")) {
      const BRANDING_BUNDLE_URI = "chrome://branding/locale/brand.properties";
      var brandShortName = Services.strings.
                           createBundle(BRANDING_BUNDLE_URI).
                           GetStringFromName("brandShortName");

      var errorStr = this.getString("load-js-data-url-error");
      Services.prompt.alert(aWindow, brandShortName, errorStr);
      return false;
    }
    return true;
  },

  /**
   * Check whether or not the given node represents a removable entry (either in
   * history or in bookmarks).
   *
   * @param aNode
   *        a node, except the root node of a query.
   * @param aView
   *        The view originating the request.
   * @return true if the aNode represents a removable entry, false otherwise.
   */
  canUserRemove(aNode, aView) {
    let parentNode = aNode.parent;
    if (!parentNode) {
      // canUserRemove doesn't accept root nodes.
      return false;
    }

    // Is it a query pointing to one of the special root folders?
    if (PlacesUtils.nodeIsQuery(parentNode)) {
      if (PlacesUtils.nodeIsFolder(aNode)) {
        let guid = PlacesUtils.getConcreteItemGuid(aNode);
        // If the parent folder is not a folder, it must be a query, and so this node
        // cannot be removed.
        if (PlacesUtils.isRootItem(guid)) {
          return false;
        }
      } else if (PlacesUtils.isVirtualLeftPaneItem(aNode.bookmarkGuid)) {
        // If the item is a left-pane top-level item, it can't be removed.
        return false;
      }
    }

    // If it's not a bookmark, we can remove it unless it's a child of a
    // livemark.
    if (aNode.itemId == -1) {
      // Rather than executing a db query, checking the existence of the feedURI
      // annotation, detect livemark children by the fact that they are the only
      // direct non-bookmark children of bookmark folders.
      return !PlacesUtils.nodeIsFolder(parentNode);
    }

    // Generally it's always possible to remove children of a query.
    if (PlacesUtils.nodeIsQuery(parentNode))
      return true;

    // Otherwise it has to be a child of an editable folder.
    return !this.isFolderReadOnly(parentNode, aView);
  },

  /**
   * DO NOT USE THIS API IN ADDONS. IT IS VERY LIKELY TO CHANGE WHEN THE SWITCH
   * TO GUIDS IS COMPLETE (BUG 1071511).
   *
   * Check whether or not the given Places node points to a folder which
   * should not be modified by the user (i.e. its children should be unremovable
   * and unmovable, new children should be disallowed, etc).
   * These semantics are not inherited, meaning that read-only folder may
   * contain editable items (for instance, the places root is read-only, but all
   * of its direct children aren't).
   *
   * You should only pass folder nodes.
   *
   * @param placesNode
   *        any folder result node.
   * @param view
   *        The view originating the request.
   * @throws if placesNode is not a folder result node or views is invalid.
   * @note livemark "folders" are considered read-only (but see bug 1072833).
   * @return true if placesNode is a read-only folder, false otherwise.
   */
  isFolderReadOnly(placesNode, view) {
    if (typeof placesNode != "object" || !PlacesUtils.nodeIsFolder(placesNode)) {
      throw new Error("invalid value for placesNode");
    }
    if (!view || typeof view != "object") {
      throw new Error("invalid value for aView");
    }
    let itemId = PlacesUtils.getConcreteItemId(placesNode);
    if (itemId == PlacesUtils.placesRootId ||
        view.controller.hasCachedLivemarkInfo(placesNode))
      return true;

    return false;
  },

  /** aItemsToOpen needs to be an array of objects of the form:
    * {uri: string, isBookmark: boolean}
    */
  _openTabset: function PUIU__openTabset(aItemsToOpen, aEvent, aWindow) {
    if (!aItemsToOpen.length)
      return;

    let browserWindow = getBrowserWindow(aWindow);
    var urls = [];
    let skipMarking = browserWindow && PrivateBrowsingUtils.isWindowPrivate(browserWindow);
    for (let item of aItemsToOpen) {
      urls.push(item.uri);
      if (skipMarking) {
        continue;
      }

      if (item.isBookmark)
        this.markPageAsFollowedBookmark(item.uri);
      else
        this.markPageAsTyped(item.uri);
    }

    // whereToOpenLink doesn't return "window" when there's no browser window
    // open (Bug 630255).
    var where = browserWindow ?
                browserWindow.whereToOpenLink(aEvent, false, true) : "window";
    if (where == "window") {
      // There is no browser window open, thus open a new one.
      var uriList = PlacesUtils.toISupportsString(urls.join("|"));
      var args = Cc["@mozilla.org/array;1"].
                  createInstance(Ci.nsIMutableArray);
      args.appendElement(uriList);
      browserWindow = Services.ww.openWindow(aWindow,
                                             AppConstants.BROWSER_CHROME_URL,
                                             null, "chrome,dialog=no,all", args);
      return;
    }

    var loadInBackground = where == "tabshifted";
    // For consistency, we want all the bookmarks to open in new tabs, instead
    // of having one of them replace the currently focused tab.  Hence we call
    // loadTabs with aReplace set to false.
    browserWindow.gBrowser.loadTabs(urls, {
      inBackground: loadInBackground,
      replace: false,
      triggeringPrincipal: Services.scriptSecurityManager.getSystemPrincipal(),
    });
  },

  openLiveMarkNodesInTabs:
  function PUIU_openLiveMarkNodesInTabs(aNode, aEvent, aView) {
    let window = aView.ownerWindow;

    PlacesUtils.livemarks.getLivemark({id: aNode.itemId})
      .then(aLivemark => {
        let urlsToOpen = [];

        let nodes = aLivemark.getNodesForContainer(aNode);
        for (let node of nodes) {
          urlsToOpen.push({uri: node.uri, isBookmark: false});
        }

        if (OpenInTabsUtils.confirmOpenInTabs(urlsToOpen.length, window)) {
          this._openTabset(urlsToOpen, aEvent, window);
        }
      }, Cu.reportError);
  },

  openContainerNodeInTabs:
  function PUIU_openContainerInTabs(aNode, aEvent, aView) {
    let window = aView.ownerWindow;

    let urlsToOpen = PlacesUtils.getURLsForContainerNode(aNode);
    if (OpenInTabsUtils.confirmOpenInTabs(urlsToOpen.length, window)) {
      this._openTabset(urlsToOpen, aEvent, window);
    }
  },

  openURINodesInTabs: function PUIU_openURINodesInTabs(aNodes, aEvent, aView) {
    let window = aView.ownerWindow;

    let urlsToOpen = [];
    for (var i = 0; i < aNodes.length; i++) {
      // Skip over separators and folders.
      if (PlacesUtils.nodeIsURI(aNodes[i]))
        urlsToOpen.push({uri: aNodes[i].uri, isBookmark: PlacesUtils.nodeIsBookmark(aNodes[i])});
    }
    this._openTabset(urlsToOpen, aEvent, window);
  },

  /**
   * Loads the node's URL in the appropriate tab or window given the
   * user's preference specified by modifier keys tracked by a
   * DOM mouse/key event.
   * @param   aNode
   *          An uri result node.
   * @param   aEvent
   *          The DOM mouse/key event with modifier keys set that track the
   *          user's preferred destination window or tab.
   */
  openNodeWithEvent:
  function PUIU_openNodeWithEvent(aNode, aEvent) {
    let window = aEvent.target.ownerGlobal;

    let browserWindow = getBrowserWindow(window);

    let where = window.whereToOpenLink(aEvent, false, true);
    if (this.loadBookmarksInTabs && PlacesUtils.nodeIsBookmark(aNode)) {
      if (where == "current" && !aNode.uri.startsWith("javascript:")) {
        where = "tab";
      }
      if (where == "tab" && browserWindow.isTabEmpty(browserWindow.gBrowser.selectedTab)) {
        where = "current";
      }
    }

    this._openNodeIn(aNode, where, window);
  },

  /**
   * Loads the node's URL in the appropriate tab or window.
   * see also openUILinkIn
   */
  openNodeIn: function PUIU_openNodeIn(aNode, aWhere, aView, aPrivate) {
    let window = aView.ownerWindow;
    this._openNodeIn(aNode, aWhere, window, aPrivate);
  },

  _openNodeIn: function PUIU__openNodeIn(aNode, aWhere, aWindow, aPrivate = false) {
    if (aNode && PlacesUtils.nodeIsURI(aNode) &&
        this.checkURLSecurity(aNode, aWindow)) {
      let isBookmark = PlacesUtils.nodeIsBookmark(aNode);

      if (!PrivateBrowsingUtils.isWindowPrivate(aWindow)) {
        if (isBookmark)
          this.markPageAsFollowedBookmark(aNode.uri);
        else
          this.markPageAsTyped(aNode.uri);
      }

      const isJavaScriptURL = aNode.uri.startsWith("javascript:");
      aWindow.openTrustedLinkIn(aNode.uri, aWhere, {
        allowPopups: isJavaScriptURL,
        inBackground: this.loadBookmarksInBackground,
        allowInheritPrincipal: isJavaScriptURL && aWhere == "current",
        private: aPrivate,
      });
    }
  },

  /**
   * Helper for guessing scheme from an url string.
   * Used to avoid nsIURI overhead in frequently called UI functions.
   *
   * @param {string} href The url to guess the scheme from.
   * @return guessed scheme for this url string.
   * @note this is not supposed be perfect, so use it only for UI purposes.
   */
  guessUrlSchemeForUI(href) {
    return href.substr(0, href.indexOf(":"));
  },

  getBestTitle: function PUIU_getBestTitle(aNode, aDoNotCutTitle) {
    var title;
    if (!aNode.title && PlacesUtils.nodeIsURI(aNode)) {
      // if node title is empty, try to set the label using host and filename
      // Services.io.newURI will throw if aNode.uri is not a valid URI
      try {
        var uri = Services.io.newURI(aNode.uri);
        var host = uri.host;
        var fileName = uri.QueryInterface(Ci.nsIURL).fileName;
        // if fileName is empty, use path to distinguish labels
        if (aDoNotCutTitle) {
          title = host + uri.pathQueryRef;
        } else {
          title = host + (fileName ?
                           (host ? "/" + this.ellipsis + "/" : "") + fileName :
                           uri.pathQueryRef);
        }
      } catch (e) {
        // Use (no title) for non-standard URIs (data:, javascript:, ...)
        title = "";
      }
    } else
      title = aNode.title;

    return title || this.getString("noTitle");
  },

  shouldShowTabsFromOtherComputersMenuitem() {
    let weaveOK = Weave.Status.checkSetup() != Weave.CLIENT_NOT_CONFIGURED &&
                  Weave.Svc.Prefs.get("firstSync", "") != "notReady";
    return weaveOK;
  },

  /**
   * WARNING TO ADDON AUTHORS: DO NOT USE THIS METHOD. IT'S LIKELY TO BE REMOVED IN A
   * FUTURE RELEASE.
   *
   * Checks if a place: href represents a folder shortcut.
   *
   * @param queryString
   *        the query string to check (a place: href)
   * @return whether or not queryString represents a folder shortcut.
   * @throws if queryString is malformed.
   */
  isFolderShortcutQueryString(queryString) {
    // Based on GetSimpleBookmarksQueryFolder in nsNavHistory.cpp.

    let query = {}, options = {};
    PlacesUtils.history.queryStringToQuery(queryString, query, options);
    query = query.value;
    options = options.value;
    return query.folderCount == 1 &&
           !query.hasBeginTime &&
           !query.hasEndTime &&
           !query.hasDomain &&
           !query.hasURI &&
           !query.hasSearchTerms &&
           !query.tags.length == 0 &&
           options.maxResults == 0;
  },

  /**
   * Helpers for consumers of editBookmarkOverlay which don't have a node as their input.
   *
   * Given a bookmark object for either a url bookmark or a folder, returned by
   * Bookmarks.fetch (see Bookmark.jsm), this creates a node-like object suitable for
   * initialising the edit overlay with it.
   *
   * @param aFetchInfo
   *        a bookmark object returned by Bookmarks.fetch.
   * @return a node-like object suitable for initialising editBookmarkOverlay.
   * @throws if aFetchInfo is representing a separator.
   */
  async promiseNodeLikeFromFetchInfo(aFetchInfo) {
    if (aFetchInfo.itemType == PlacesUtils.bookmarks.TYPE_SEPARATOR)
      throw new Error("promiseNodeLike doesn't support separators");

    let parent = {
      itemId: await PlacesUtils.promiseItemId(aFetchInfo.parentGuid),
      bookmarkGuid: aFetchInfo.parentGuid,
      type: Ci.nsINavHistoryResultNode.RESULT_TYPE_FOLDER,
    };

    return Object.freeze({
      itemId: await PlacesUtils.promiseItemId(aFetchInfo.guid),
      bookmarkGuid: aFetchInfo.guid,
      title: aFetchInfo.title,
      uri: aFetchInfo.url !== undefined ? aFetchInfo.url.href : "",

      get type() {
        if (aFetchInfo.itemType == PlacesUtils.bookmarks.TYPE_FOLDER)
          return Ci.nsINavHistoryResultNode.RESULT_TYPE_FOLDER;

        if (this.uri.length == 0)
          throw new Error("Unexpected item type");

        if (/^place:/.test(this.uri)) {
          if (this.isFolderShortcutQueryString(this.uri))
            return Ci.nsINavHistoryResultNode.RESULT_TYPE_FOLDER_SHORTCUT;

          return Ci.nsINavHistoryResultNode.RESULT_TYPE_QUERY;
        }

        return Ci.nsINavHistoryResultNode.RESULT_TYPE_URI;
      },

      get parent() {
        return parent;
      },
    });
  },

  /**
   * This function wraps potentially large places transaction operations
   * with batch notifications to the result node, hence switching the views
   * to batch mode.
   *
   * @param {nsINavHistoryResult} resultNode The result node to turn on batching.
   * @note If resultNode is not supplied, the function will pass-through to
   *       functionToWrap.
   * @param {Integer} itemsBeingChanged The count of items being changed. If the
   *                                    count is lower than a threshold, then
   *                                    batching won't be set.
   * @param {Function} functionToWrap The function to
   */
  async batchUpdatesForNode(resultNode, itemsBeingChanged, functionToWrap) {
    if (!resultNode) {
      await functionToWrap();
      return;
    }

    resultNode = resultNode.QueryInterface(Ci.nsINavBookmarkObserver);

    if (itemsBeingChanged > ITEM_CHANGED_BATCH_NOTIFICATION_THRESHOLD) {
      resultNode.onBeginUpdateBatch();
    }

    try {
      await functionToWrap();
    } finally {
      if (itemsBeingChanged > ITEM_CHANGED_BATCH_NOTIFICATION_THRESHOLD) {
        resultNode.onEndUpdateBatch();
      }
    }
  },

  /**
   * Processes a set of transfer items that have been dropped or pasted.
   * Batching will be applied where necessary.
   *
   * @param {Array} items A list of unwrapped nodes to process.
   * @param {Object} insertionPoint The requested point for insertion.
   * @param {Boolean} doCopy Set to true to copy the items, false will move them
   *                         if possible.
   * @paramt {Object} view The view that should be used for batching.
   * @return {Array} Returns an empty array when the insertion point is a tag, else
   *                 returns an array of copied or moved guids.
   */
  async handleTransferItems(items, insertionPoint, doCopy, view) {
    let transactions;
    let itemsCount;
    if (insertionPoint.isTag) {
      let urls = items.filter(item => "uri" in item).map(item => item.uri);
      itemsCount = urls.length;
      transactions = [PlacesTransactions.Tag({ urls, tag: insertionPoint.tagName })];
    } else {
      let insertionIndex = await insertionPoint.getIndex();
      itemsCount = items.length;
      transactions = getTransactionsForTransferItems(
        items, insertionIndex, insertionPoint.guid, !doCopy);
    }

    // Check if we actually have something to add, if we don't it probably wasn't
    // valid, or it was moving to the same location, so just ignore it.
    if (!transactions.length) {
      return [];
    }

    let guidsToSelect = [];
    let resultForBatching = getResultForBatching(view);

    // If we're inserting into a tag, we don't get the guid, so we'll just
    // pass the transactions direct to the batch function.
    let batchingItem = transactions;
    if (!insertionPoint.isTag) {
      // If we're not a tag, then we need to get the ids of the items to select.
      batchingItem = async () => {
        for (let transaction of transactions) {
          let result = await transaction.transact();
          guidsToSelect = guidsToSelect.concat(result);
        }
      };
    }

    await this.batchUpdatesForNode(resultForBatching, itemsCount, async () => {
      await PlacesTransactions.batch(batchingItem);
    });

    return guidsToSelect;
  },

  onSidebarTreeClick(event) {
    // right-clicks are not handled here
    if (event.button == 2)
      return;

    let tree = event.target.parentNode;
    let tbo = tree.treeBoxObject;
    let cell = tbo.getCellAt(event.clientX, event.clientY);
    if (cell.row == -1 || cell.childElt == "twisty")
      return;

    // getCoordsForCellItem returns the x coordinate in logical coordinates
    // (i.e., starting from the left and right sides in LTR and RTL modes,
    // respectively.)  Therefore, we make sure to exclude the blank area
    // before the tree item icon (that is, to the left or right of it in
    // LTR and RTL modes, respectively) from the click target area.
    let win = tree.ownerGlobal;
    let rect = tbo.getCoordsForCellItem(cell.row, cell.col, "image");
    let isRTL = win.getComputedStyle(tree).direction == "rtl";
    let mouseInGutter = isRTL ? event.clientX > rect.x
                              : event.clientX < rect.x;

    let metaKey = AppConstants.platform === "macosx" ? event.metaKey
                                                     : event.ctrlKey;
    let modifKey = metaKey || event.shiftKey;
    let isContainer = tbo.view.isContainer(cell.row);
    let openInTabs = isContainer &&
                     (event.button == 1 || (event.button == 0 && modifKey)) &&
                     PlacesUtils.hasChildURIs(tree.view.nodeForTreeIndex(cell.row));

    if (event.button == 0 && isContainer && !openInTabs) {
      tbo.view.toggleOpenState(cell.row);
    } else if (!mouseInGutter && openInTabs &&
               event.originalTarget.localName == "treechildren") {
      tbo.view.selection.select(cell.row);
      this.openContainerNodeInTabs(tree.selectedNode, event, tree);
    } else if (!mouseInGutter && !isContainer &&
               event.originalTarget.localName == "treechildren") {
      // Clear all other selection since we're loading a link now. We must
      // do this *before* attempting to load the link since openURL uses
      // selection as an indication of which link to load.
      tbo.view.selection.select(cell.row);
      this.openNodeWithEvent(tree.selectedNode, event);
    }
  },

  onSidebarTreeKeyPress(event) {
    let node = event.target.selectedNode;
    if (node) {
      if (event.keyCode == event.DOM_VK_RETURN)
        this.openNodeWithEvent(node, event);
    }
  },

  /**
   * The following function displays the URL of a node that is being
   * hovered over.
   */
  onSidebarTreeMouseMove(event) {
    let treechildren = event.target;
    if (treechildren.localName != "treechildren")
      return;

    let tree = treechildren.parentNode;
    let cell = tree.treeBoxObject.getCellAt(event.clientX, event.clientY);

    // cell.row is -1 when the mouse is hovering an empty area within the tree.
    // To avoid showing a URL from a previously hovered node for a currently
    // hovered non-url node, we must clear the moused-over URL in these cases.
    if (cell.row != -1) {
      let node = tree.view.nodeForTreeIndex(cell.row);
      if (PlacesUtils.nodeIsURI(node)) {
        this.setMouseoverURL(node.uri, tree.ownerGlobal);
        return;
      }
    }
    this.setMouseoverURL("", tree.ownerGlobal);
  },

  setMouseoverURL(url, win) {
    // When the browser window is closed with an open sidebar, the sidebar
    // unload event happens after the browser's one.  In this case
    // top.XULBrowserWindow has been nullified already.
    if (win.top.XULBrowserWindow) {
      win.top.XULBrowserWindow.setOverLink(url, null);
    }
  },
};

// These are lazy getters to avoid importing PlacesUtils immediately.
XPCOMUtils.defineLazyGetter(PlacesUIUtils, "PLACES_FLAVORS", () => {
  return [PlacesUtils.TYPE_X_MOZ_PLACE_CONTAINER,
          PlacesUtils.TYPE_X_MOZ_PLACE_SEPARATOR,
          PlacesUtils.TYPE_X_MOZ_PLACE];
});
XPCOMUtils.defineLazyGetter(PlacesUIUtils, "URI_FLAVORS", () => {
  return [PlacesUtils.TYPE_X_MOZ_URL,
          TAB_DROP_TYPE,
          PlacesUtils.TYPE_UNICODE];
});
XPCOMUtils.defineLazyGetter(PlacesUIUtils, "SUPPORTED_FLAVORS", () => {
  return [...PlacesUIUtils.PLACES_FLAVORS,
          ...PlacesUIUtils.URI_FLAVORS];
});

XPCOMUtils.defineLazyGetter(PlacesUIUtils, "ellipsis", function() {
  return Services.prefs.getComplexValue("intl.ellipsis",
                                        Ci.nsIPrefLocalizedString).data;
});

XPCOMUtils.defineLazyPreferenceGetter(PlacesUIUtils, "loadBookmarksInBackground",
                                      PREF_LOAD_BOOKMARKS_IN_BACKGROUND, false);
XPCOMUtils.defineLazyPreferenceGetter(PlacesUIUtils, "loadBookmarksInTabs",
                                      PREF_LOAD_BOOKMARKS_IN_TABS, false);
XPCOMUtils.defineLazyPreferenceGetter(PlacesUIUtils, "openInTabClosesMenu",
  "browser.bookmarks.openInTabClosesMenu", false);

/**
 * Determines if an unwrapped node can be moved.
 *
 * @param unwrappedNode
 *        A node unwrapped by PlacesUtils.unwrapNodes().
 * @return True if the node can be moved, false otherwise.
 */
function canMoveUnwrappedNode(unwrappedNode) {
  if ((unwrappedNode.concreteGuid && PlacesUtils.isRootItem(unwrappedNode.concreteGuid)) ||
      (unwrappedNode.guid && PlacesUtils.isRootItem(unwrappedNode.guid))) {
    return false;
  }

  let parentGuid = unwrappedNode.parentGuid;
  // If there's no parent Guid, this was likely a virtual query that returns
  // bookmarks, such as a tags query.
  if (!parentGuid ||
      parentGuid == PlacesUtils.bookmarks.rootGuid) {
    return false;
  }

  return true;
}

/**
 * This gets the most appropriate item for using for batching. In the case of multiple
 * views being related, the method returns the most expensive result to batch.
 * For example, if it detects the left-hand library pane, then it will look for
 * and return the reference to the right-hand pane.
 *
 * @param {Object} viewOrElement The item to check.
 * @return {Object} Will return the best result node to batch, or null
 *                  if one could not be found.
 */
function getResultForBatching(viewOrElement) {
  if (viewOrElement && Element.isInstance(viewOrElement) &&
      viewOrElement.id === "placesList") {
    // Note: fall back to the existing item if we can't find the right-hane pane.
    viewOrElement = viewOrElement.ownerDocument.getElementById("placeContent") || viewOrElement;
  }

  if (viewOrElement && viewOrElement.result) {
    return viewOrElement.result;
  }

  return null;
}


/**
 * Processes a set of transfer items and returns transactions to insert or
 * move them.
 *
 * @param {Array} items A list of unwrapped nodes to get transactions for.
 * @param {Integer} insertionIndex The requested index for insertion.
 * @param {String} insertionParentGuid The guid of the parent folder to insert
 *                                     or move the items to.
 * @param {Boolean} doMove Set to true to MOVE the items if possible, false will
 *                         copy them.
 * @return {Array} Returns an array of created PlacesTransactions.
 */
function getTransactionsForTransferItems(items, insertionIndex,
                                         insertionParentGuid, doMove) {
  let canMove = true;
  for (let item of items) {
    if (!PlacesUIUtils.SUPPORTED_FLAVORS.includes(item.type)) {
      throw new Error(`Unsupported '${item.type}' data type`);
    }

    // Work out if this is data from the same app session we're running in.
    if (!("instanceId" in item) || item.instanceId != PlacesUtils.instanceId) {
      if (item.type == PlacesUtils.TYPE_X_MOZ_PLACE_CONTAINER) {
        throw new Error("Can't copy a container from a legacy-transactions build");
      }
      // Only log if this is one of "our" types as external items, e.g. drag from
      // url bar to toolbar, shouldn't complain.
      if (PlacesUIUtils.PLACES_FLAVORS.includes(item.type)) {
        Cu.reportError("Tried to move an unmovable Places " +
                       "node, reverting to a copy operation.");
      }

      // We can never move from an external copy.
      canMove = false;
    }

    if (doMove && canMove) {
      canMove = canMoveUnwrappedNode(item);
    }
  }

  if (doMove && !canMove) {
    doMove = false;
  }

  if (doMove) {
    // Move is simple, we pass the transaction a list of GUIDs and where to move
    // them to.
    return [PlacesTransactions.Move({
      guids: items.map(item => item.itemGuid),
      newParentGuid: insertionParentGuid,
      newIndex: insertionIndex,
    })];
  }

  return getTransactionsForCopy(items, insertionIndex, insertionParentGuid);
}

/**
 * Processes a set of transfer items and returns an array of transactions.
 *
 * @param {Array} items A list of unwrapped nodes to get transactions for.
 * @param {Integer} insertionIndex The requested index for insertion.
 * @param {String} insertionParentGuid The guid of the parent folder to insert
 *                                     or move the items to.
 * @return {Array} Returns an array of created PlacesTransactions.
 */
function getTransactionsForCopy(items, insertionIndex,
                                insertionParentGuid) {
  let transactions = [];
  let index = insertionIndex;

  for (let item of items) {
    let transaction;
    let guid = item.itemGuid;

    if (PlacesUIUtils.PLACES_FLAVORS.includes(item.type) &&
        // For anything that is comming from within this session, we do a
        // direct copy, otherwise we fallback and form a new item below.
        "instanceId" in item && item.instanceId == PlacesUtils.instanceId &&
        // If the Item doesn't have a guid, this could be a virtual tag query or
        // other item, so fallback to inserting a new bookmark with the URI.
        guid &&
        // For virtual root items, we fallback to creating a new bookmark, as
        // we want a shortcut to be created, not a full tree copy.
        !PlacesUtils.bookmarks.isVirtualRootItem(guid) &&
        !PlacesUtils.isVirtualLeftPaneItem(guid)) {
      transaction = PlacesTransactions.Copy({
        excludingAnnotation: "Places/SmartBookmark",
        guid,
        newIndex: index,
        newParentGuid: insertionParentGuid,
      });
    } else if (item.type == PlacesUtils.TYPE_X_MOZ_PLACE_SEPARATOR) {
      transaction = PlacesTransactions.NewSeparator({
        index,
        parentGuid: insertionParentGuid,
      });
    } else {
      let title = item.type != PlacesUtils.TYPE_UNICODE ? item.title : item.uri;
      transaction = PlacesTransactions.NewBookmark({
        index,
        parentGuid: insertionParentGuid,
        title,
        url: item.uri,
      });
    }

    transactions.push(transaction);

    if (index != -1) {
      index++;
    }
  }
  return transactions;
}

function getBrowserWindow(aWindow) {
  // Prefer the caller window if it's a browser window, otherwise use
  // the top browser window.
  return aWindow && aWindow.document.documentElement.getAttribute("windowtype") == "navigator:browser" ?
    aWindow : BrowserWindowTracker.getTopWindow();
}