Merge f-t to m-c, a=merge
authorPhil Ringnalda <philringnalda@gmail.com>
Sat, 21 Mar 2015 12:47:01 -0700
changeset 263735 82ae3b4e221521df079841de9f3a37224a407656
parent 263714 400c2a449d8d76e4d6f8f53730a2b5ec8c4d3590 (current diff)
parent 263734 70cf7bc0d63860e28cbb681871c76970a3450d07 (diff)
child 263751 2921b421255b6c35e812aaafad5cbe850830ee30
child 263781 d65328c6afe95c977f6670d55c08a8587ca487b9
child 263805 e103b6f3d896a53d46fdc57705dcee255db6522b
push id4718
push userraliiev@mozilla.com
push dateMon, 11 May 2015 18:39:53 +0000
treeherdermozilla-beta@c20c4ef55f08 [default view] [failures only]
perfherder[talos] [build metrics] [platform microbench] (compared to previous push)
reviewersmerge
milestone39.0a1
first release with
nightly linux32
82ae3b4e2215 / 39.0a1 / 20150322030216 / files
nightly linux64
82ae3b4e2215 / 39.0a1 / 20150322030216 / files
nightly mac
82ae3b4e2215 / 39.0a1 / 20150322030216 / files
nightly win32
82ae3b4e2215 / 39.0a1 / 20150322030216 / files
nightly win64
82ae3b4e2215 / 39.0a1 / 20150322030216 / files
last release without
nightly linux32
nightly linux64
nightly mac
nightly win32
nightly win64
releases
nightly linux32
nightly linux64
nightly mac
nightly win32
nightly win64
Merge f-t to m-c, a=merge
--- a/browser/app/profile/firefox.js
+++ b/browser/app/profile/firefox.js
@@ -1876,10 +1876,11 @@ pref("dom.ipc.reportProcessHangs", true)
 
 #ifndef NIGHTLY_BUILD
 // Disable reader mode by default.
 pref("reader.parse-on-load.enabled", false);
 #endif
 
 // Disable ReadingList browser UI by default.
 pref("browser.readinglist.enabled", false);
+pref("browser.readinglist.sidebarEverOpened", false);
 // Enable the readinglist engine by default.
 pref("readinglist.scheduler.enabled", true);
--- a/browser/base/content/browser-readinglist.js
+++ b/browser/base/content/browser-readinglist.js
@@ -173,17 +173,17 @@ let ReadingListUI = {
       Favicons.getFaviconURLForPage(item.uri, uri => {
         if (uri) {
           menuitem.setAttribute("image",
                                 Favicons.getFaviconLinkForIcon(uri).spec);
         }
       });
 
       target.insertBefore(menuitem, insertPoint);
-    });
+    }, {sort: "addedOn", descending: true});
 
     if (!hasItems) {
       let menuitem = document.createElement("menuitem");
       let bundle =
         Services.strings.createBundle("chrome://browser/locale/places/places.properties");
       menuitem.setAttribute("label", bundle.GetStringFromName("bookmarksMenuEmptyFolder"));
       menuitem.setAttribute("class", "bookmark-item");
       menuitem.setAttribute("disabled", true);
@@ -237,22 +237,30 @@ let ReadingListUI = {
     if (this.enabled && state == "valid") {
       uri = gBrowser.currentURI;
       if (uri.schemeIs("about"))
         uri = ReaderParent.parseReaderUrl(uri.spec);
       else if (!uri.schemeIs("http") && !uri.schemeIs("https"))
         uri = null;
     }
 
+    let msg = {topic: "UpdateActiveItem", url: null};
     if (!uri) {
       this.toolbarButton.setAttribute("hidden", true);
+      if (this.isSidebarOpen)
+        document.getElementById("sidebar").contentWindow.postMessage(msg, "*");
       return;
     }
 
-    let isInList = yield ReadingList.containsURL(uri);
+    let isInList = yield ReadingList.hasItemForURL(uri);
+    if (this.isSidebarOpen) {
+      if (isInList)
+        msg.url = typeof uri == "string" ? uri : uri.spec;
+      document.getElementById("sidebar").contentWindow.postMessage(msg, "*");
+    }
     this.setToolbarButtonState(isInList);
   }),
 
   /**
    * Set the state of the ReadingList toolbar button in the urlbar.
    * If the current tab's page is in the ReadingList (active), sets the button
    * to allow removing the page. Otherwise, sets the button to allow adding the
    * page (not active).
@@ -279,17 +287,17 @@ let ReadingListUI = {
    */
   togglePageByBrowser: Task.async(function* (browser) {
     let uri = browser.currentURI;
     if (uri.spec.startsWith("about:reader?"))
       uri = ReaderParent.parseReaderUrl(uri.spec);
     if (!uri)
       return;
 
-    let item = yield ReadingList.getItemForURL(uri);
+    let item = yield ReadingList.itemForURL(uri);
     if (item) {
       yield item.delete();
     } else {
       yield ReadingList.addItemFromBrowser(browser, uri);
     }
   }),
 
   /**
@@ -310,18 +318,25 @@ let ReadingListUI = {
   },
 
   /**
    * ReadingList event handler for when an item is added.
    *
    * @param {ReadingListItem} item - Item added.
    */
   onItemAdded(item) {
+    if (!Services.prefs.getBoolPref("browser.readinglist.sidebarEverOpened")) {
+      SidebarUI.show("readingListSidebar");
+    }
     if (this.isItemForCurrentBrowser(item)) {
       this.setToolbarButtonState(true);
+      if (this.isSidebarOpen) {
+        let msg = {topic: "UpdateActiveItem", url: item.url};
+        document.getElementById("sidebar").contentWindow.postMessage(msg, "*");
+      }
     }
   },
 
   /**
    * ReadingList event handler for when an item is deleted.
    *
    * @param {ReadingListItem} item - Item deleted.
    */
--- a/browser/base/content/browser.css
+++ b/browser/base/content/browser.css
@@ -910,17 +910,16 @@ chatbox {
   -moz-binding: url("chrome://browser/content/socialchat.xml#chatbox");
   transition: height 150ms ease-out, width 150ms ease-out;
   height: 285px;
   width: 260px; /* CHAT_WIDTH_OPEN in socialchat.xml */
 }
 
 chatbox[large="true"] {
   width: 300px;
-  heigth: 272px;
 }
 
 chatbox[minimized="true"] {
   width: 160px;
   height: 20px; /* CHAT_WIDTH_MINIMIZED in socialchat.xml */
 }
 
 chatbar {
--- a/browser/base/content/newtab/grid.js
+++ b/browser/base/content/newtab/grid.js
@@ -163,17 +163,18 @@ let gGrid = {
       '  <span class="newtab-thumbnail"/>' +
       '  <span class="newtab-thumbnail enhanced-content"/>' +
       '  <span class="newtab-title"/>' +
       '</a>' +
       '<input type="button" title="' + newTabString("pin") + '"' +
       '       class="newtab-control newtab-control-pin"/>' +
       '<input type="button" title="' + newTabString("block") + '"' +
       '       class="newtab-control newtab-control-block"/>' +
-      '<span class="newtab-sponsored">' + newTabString("sponsored.button") + '</span>';
+      '<span class="newtab-sponsored">' + newTabString("sponsored.button") + '</span>' +
+      '<span class="newtab-suggested"/>';
 
     this._siteFragment = document.createDocumentFragment();
     this._siteFragment.appendChild(site);
   },
 
   /**
    * Make sure the correct number of rows and columns are visible
    */
@@ -184,17 +185,18 @@ let gGrid = {
     // Same goes for the grid if that's not ready yet.
     if (!this.isDocumentLoaded || !this._ready) {
       return;
     }
 
     // Save the cell's computed height/width including margin and border
     if (this._cellMargin === undefined) {
       let refCell = document.querySelector(".newtab-cell");
-      this._cellMargin = parseFloat(getComputedStyle(refCell).marginTop) * 2;
+      this._cellMargin = parseFloat(getComputedStyle(refCell).marginTop) +
+        parseFloat(getComputedStyle(refCell).marginBottom);
       this._cellHeight = refCell.offsetHeight + this._cellMargin;
       this._cellWidth = refCell.offsetWidth + this._cellMargin;
     }
 
     let availSpace = document.documentElement.clientHeight - this._cellMargin -
                      document.querySelector("#newtab-search-container").offsetHeight;
     let visibleRows = Math.floor(availSpace / this._cellHeight);
     this._node.style.height = this._computeHeight() + "px";
--- a/browser/base/content/newtab/newTab.css
+++ b/browser/base/content/newtab/newTab.css
@@ -154,17 +154,17 @@ input[type=button] {
 #newtab-grid[page-disabled] {
   pointer-events: none;
 }
 
 /* CELLS */
 .newtab-cell {
   display: -moz-box;
   height: 180px;
-  margin: 20px 10px;
+  margin: 20px 10px 85px;
   width: 290px;
 }
 
 /* SITES */
 .newtab-site {
   position: relative;
   -moz-box-flex: 1;
   transition: 100ms ease-out;
@@ -188,30 +188,55 @@ input[type=button] {
   left: 0;
   top: 0;
   right: 0;
   bottom: 0;
 }
 
 /* TITLES */
 .newtab-sponsored,
-.newtab-title {
-  bottom: -26px;
+.newtab-title,
+.newtab-suggested {
   overflow: hidden;
   position: absolute;
   right: 0;
   text-align: center;
+}
+
+.newtab-sponsored,
+.newtab-title {
+  bottom: -26px;
   white-space: nowrap;
+  text-overflow: ellipsis;
+  font-size: 13px;
+}
+
+.newtab-suggested {
+  border: 1px solid #dcdcdc;
+  border-radius: 2px;
+  cursor: pointer;
+  font-size: 12px;
+  height: 17px;
+  line-height: 17px;
+  margin-bottom: -1px;
+  padding: 2px 8px;
+  display: none;
+  margin-left: auto;
+  margin-right: auto;
+  left: 0;
+  top: 215px;
+}
+
+.newtab-suggested-bounds {
+  max-height: 51px; /* 51 / 17 = 3 lines maximum */
 }
 
 .newtab-title {
-  font-size: 13px;
   left: 0;
   padding-top: 14px;
-  text-overflow: ellipsis;
 }
 
 .newtab-sponsored {
   border: 1px solid #dcdcdc;
   border-radius: 2px;
   cursor: pointer;
   display: none;
   font-family: Arial;
@@ -232,16 +257,20 @@ input[type=button] {
   left: 0;
   right: auto;
 }
 
 .newtab-site:-moz-any([type=enhanced], [type=sponsored]) .newtab-sponsored {
   display: block;
 }
 
+.newtab-site[type=related] .newtab-suggested {
+  display: table;
+}
+
 .sponsored-explain,
 .sponsored-explain a {
   color: white;
 }
 
 .sponsored-explain {
   background-color: rgba(51, 51, 51, 0.95);
   border-bottom-left-radius: 6px;
@@ -457,17 +486,17 @@ input[type=button] {
 #newtab-customize-panel > .panel-arrowcontainer > .panel-arrowcontent,
 #newtab-search-panel > .panel-arrowcontainer > .panel-arrowcontent {
   padding: 0;
 }
 
 .newtab-customize-panel-item,
 .newtab-search-panel-engine,
 #newtab-search-manage {
-  padding: 4px 24px;
+  padding: 10px 10px 10px 25px;
 }
 
 .newtab-customize-panel-item:not(:last-child),
 .newtab-search-panel-engine {
   border-bottom: 1px solid threedshadow;
 }
 
 .newtab-search-panel-engine > image {
@@ -479,16 +508,20 @@ input[type=button] {
 
 .newtab-customize-panel-item > label,
 .newtab-search-panel-engine > label,
 #newtab-search-manage > label {
   padding: 0;
   margin: 0;
 }
 
+.newtab-customize-panel-item:not([selected]) {
+  color: #919191;
+}
+
 .newtab-customize-panel-item[selected],
 .newtab-search-panel-engine[selected] {
   background: url("chrome://global/skin/menu/shared-menu-check.png") center left 4px no-repeat transparent;
   background-size: 16px 16px;
 }
 
 @media (min-resolution: 2dppx) {
   .newtab-customize-panel-item[selected],
--- a/browser/base/content/newtab/newTab.xul
+++ b/browser/base/content/newtab/newTab.xul
@@ -32,23 +32,23 @@
     <xul:hbox id="newtab-search-manage">
       <xul:label>&changeSearchSettings.button;</xul:label>
     </xul:hbox>
   </xul:panel>
 
   <xul:panel id="newtab-customize-panel" orient="vertical" type="arrow"
              noautohide="true" hidden="true">
     <xul:hbox id="newtab-customize-enhanced" class="newtab-customize-panel-item">
-      <xul:label>&newtab.customize.enhanced;</xul:label>
+      <xul:label>&newtab.customize.suggested;</xul:label>
     </xul:hbox>
     <xul:hbox id="newtab-customize-classic" class="newtab-customize-panel-item">
-      <xul:label>&newtab.customize.classic;</xul:label>
+      <xul:label>&newtab.customize.topsites;</xul:label>
     </xul:hbox>
     <xul:hbox id="newtab-customize-blank" class="newtab-customize-panel-item">
-      <xul:label>&newtab.customize.blank;</xul:label>
+      <xul:label>&newtab.customize.blank2;</xul:label>
     </xul:hbox>
   </xul:panel>
 
   <div id="newtab-scrollbox">
 
     <div id="newtab-vertical-margin">
 
       <div id="newtab-margin-top"/>
--- a/browser/base/content/newtab/sites.js
+++ b/browser/base/content/newtab/sites.js
@@ -126,16 +126,22 @@ Site.prototype = {
     let tooltip = (title == url ? title : title + "\n" + url);
 
     let link = this._querySelector(".newtab-link");
     link.setAttribute("title", tooltip);
     link.setAttribute("href", url);
     this._querySelector(".newtab-title").textContent = title;
     this.node.setAttribute("type", this.link.type);
 
+    if (this.link.targetedSite) {
+      let targetedSite = `<strong> ${this.link.targetedSite} </strong>`;
+      this._querySelector(".newtab-suggested").innerHTML =
+        `<div class='newtab-suggested-bounds'> ${newTabString("suggested.button", [targetedSite])} </div>`;
+    }
+
     if (this.isPinned())
       this._updateAttributes(true);
     // Capture the page if the thumbnail is missing, which will cause page.js
     // to be notified and call our refreshThumbnail() method.
     this.captureIfMissing();
     // but still display whatever thumbnail might be available now.
     this.refreshThumbnail();
   },
@@ -172,33 +178,40 @@ Site.prototype = {
 
       if (this.link.type != link.type) {
         this.node.setAttribute("type", "enhanced");
         this.enhancedId = link.directoryId;
       }
     }
   },
 
+  _ignoreHoverEvents: function(element) {
+    element.addEventListener("mouseover", () => {
+      this.cell.node.setAttribute("ignorehover", "true");
+    });
+    element.addEventListener("mouseout", () => {
+      this.cell.node.removeAttribute("ignorehover");
+    });
+  },
+
   /**
    * Adds event handlers for the site and its buttons.
    */
   _addEventHandlers: function Site_addEventHandlers() {
     // Register drag-and-drop event handlers.
     this._node.addEventListener("dragstart", this, false);
     this._node.addEventListener("dragend", this, false);
     this._node.addEventListener("mouseover", this, false);
 
-    // Specially treat the sponsored icon to prevent regular hover effects
+    // Specially treat the sponsored icon & suggested explanation
+    // text to prevent regular hover effects
     let sponsored = this._querySelector(".newtab-sponsored");
-    sponsored.addEventListener("mouseover", () => {
-      this.cell.node.setAttribute("ignorehover", "true");
-    });
-    sponsored.addEventListener("mouseout", () => {
-      this.cell.node.removeAttribute("ignorehover");
-    });
+    let suggested = this._querySelector(".newtab-suggested");
+    this._ignoreHoverEvents(sponsored);
+    this._ignoreHoverEvents(suggested);
   },
 
   /**
    * Speculatively opens a connection to the current site.
    */
   _speculativeConnect: function Site_speculativeConnect() {
     let sc = Services.io.QueryInterface(Ci.nsISpeculativeConnect);
     let uri = Services.io.newURI(this.url, null, null);
@@ -263,16 +276,22 @@ Site.prototype = {
       }
     }
     // Handle sponsored explanation link click
     else if (target.parentElement.classList.contains("sponsored-explain")) {
       action = "sponsored_link";
     }
     // Only handle primary clicks for the remaining targets
     else if (button == 0) {
+      if (target.parentElement.classList.contains("newtab-suggested") ||
+          target.classList.contains("newtab-suggested")) {
+        // Suggested explanation text should do nothing when clicked and
+        // the link in the suggested explanation should act as default.
+        return;
+      }
       aEvent.preventDefault();
       if (target.classList.contains("newtab-control-block")) {
         this.block();
         action = "block";
       }
       else if (target.classList.contains("sponsored-explain") ||
                target.classList.contains("newtab-sponsored")) {
         this._toggleSponsored();
--- a/browser/base/content/test/newtab/browser_newtab_drag_drop.js
+++ b/browser/base/content/test/newtab/browser_newtab_drag_drop.js
@@ -55,18 +55,18 @@ function runTests() {
   // pinned sites should not be pushed out of the grid (unless there are only
   // pinned ones left on the grid)
   yield setLinks("0,1,2,3,4,5,6,7,8");
   setPinnedLinks(",,,,,,,7,8");
 
   yield addNewTabPageTab();
   checkGrid("0,1,2,3,4,5,6,7p,8p");
 
-  yield simulateDrop(2, 8);
-  checkGrid("0,1,3,4,5,6,7p,8p,2p");
+  yield simulateDrop(2, 5);
+  checkGrid("0,1,3,4,5,2p,6,7p,8p");
 
   // make sure that pinned sites are re-positioned correctly
   yield setLinks("0,1,2,3,4,5,6,7,8");
   setPinnedLinks("0,1,2,,,5");
 
   yield addNewTabPageTab();
   checkGrid("0p,1p,2p,3,4,5p,6,7,8");
 
--- a/browser/base/content/test/newtab/browser_newtab_drag_drop_ext.js
+++ b/browser/base/content/test/newtab/browser_newtab_drag_drop_ext.js
@@ -30,29 +30,29 @@ function runTests() {
   yield setLinks("0,1,2,3,4,5,6,7,8");
   setPinnedLinks(",,,,,,,7,8");
 
   yield addNewTabPageTab();
   checkGrid("0,1,2,3,4,5,6,7p,8p");
 
   // force the grid to be small enough that a pinned cell could be pushed out
   Services.prefs.setIntPref(PREF_NEWTAB_COLUMNS, 3);
-  yield simulateExternalDrop(7);
-  checkGrid("0,1,2,3,4,5,7p,99p,8p");
+  yield simulateExternalDrop(5);
+  checkGrid("0,1,2,3,4,99p,5,7p,8p");
 
   // drag a new site beneath a pinned cell and make sure the pinned cell is
   // not moved
   yield setLinks("0,1,2,3,4,5,6,7,8");
   setPinnedLinks(",,,,,,,,8");
 
   yield addNewTabPageTab();
   checkGrid("0,1,2,3,4,5,6,7,8p");
 
-  yield simulateExternalDrop(7);
-  checkGrid("0,1,2,3,4,5,6,99p,8p");
+  yield simulateExternalDrop(5);
+  checkGrid("0,1,2,3,4,99p,5,6,8p");
 
   // drag a new site onto a block of pinned sites and make sure they're shifted
   // around accordingly
   yield setLinks("0,1,2,3,4,5,6,7,8");
   setPinnedLinks("0,1,2,,,,,,");
 
   yield addNewTabPageTab();
   checkGrid("0p,1p,2p");
--- a/browser/base/content/test/newtab/browser_newtab_enhanced.js
+++ b/browser/base/content/test/newtab/browser_newtab_enhanced.js
@@ -35,32 +35,32 @@ function runTests() {
 
   // Make the page have a directory link followed by a history link
   yield setLinks("-1");
 
   // Test with enhanced = false
   yield addNewTabPageTab();
   yield customizeNewTabPage("classic");
   let {type, enhanced, title} = getData(0);
+  isnot(type, "enhanced", "history link is not enhanced");
+  is(enhanced, "", "history link has no enhanced image");
+  is(title, "site#-1");
+
+  is(getData(1), null, "there is only one link and it's a history link");
+
+  // Test with enhanced = true
+  yield addNewTabPageTab();
+  yield customizeNewTabPage("enhanced");
+  ({type, enhanced, title} = getData(0));
   is(type, "organic", "directory link is organic");
   isnot(enhanced, "", "directory link has enhanced image");
   is(title, "title");
 
   is(getData(1), null, "history link pushed out by directory link");
 
-  // Test with enhanced = true
-  yield addNewTabPageTab();
-  yield customizeNewTabPage("enhanced");
-  ({type, enhanced, title} = getData(0));
-  is(type, "organic", "directory link is still organic");
-  isnot(enhanced, "", "directory link still has enhanced image");
-  is(title, "title");
-
-  is(getData(1), null, "history link still pushed out by directory link");
-
   // Test with a pinned link
   setPinnedLinks("-1");
   yield addNewTabPageTab();
   ({type, enhanced, title} = getData(0));
   is(type, "enhanced", "pinned history link is enhanced");
   isnot(enhanced, "", "pinned history link has enhanced image");
   is(title, "title");
 
--- a/browser/components/preferences/in-content/content.js
+++ b/browser/components/preferences/in-content/content.js
@@ -21,17 +21,17 @@ var gContentPane = {
 
     // Show translation preferences if we may:
     const prefName = "browser.translation.ui.show";
     if (Services.prefs.getBoolPref(prefName)) {
       let row = document.getElementById("translationBox");
       row.removeAttribute("hidden");
     }
 
-    setEventListener("font.language.group", "blur",
+    setEventListener("font.language.group", "change",
       gContentPane._rebuildFonts);
     setEventListener("popupPolicyButton", "command",
       gContentPane.showPopupExceptions);
     setEventListener("advancedFonts", "command",
       gContentPane.configureFonts);
     setEventListener("colors", "command",
       gContentPane.configureColors);
     setEventListener("chooseLanguage", "command",
--- a/browser/components/readinglist/ReadingList.jsm
+++ b/browser/components/readinglist/ReadingList.jsm
@@ -28,18 +28,20 @@ XPCOMUtils.defineLazyModuleGetter(this, 
   });
   let formatter = new Log.BasicFormatter();
   parentLog.addAppender(new Log.ConsoleAppender(formatter));
   parentLog.addAppender(new Log.DumpAppender(formatter));
 }
 let log = Log.repository.getLogger("readinglist.api");
 
 
-// Names of basic properties on ReadingListItem.
-const ITEM_BASIC_PROPERTY_NAMES = `
+// Each ReadingListItem has a _record property, an object containing the raw
+// data from the server and local store.  These are the names of the properties
+// in that object.
+const ITEM_RECORD_PROPERTIES = `
   guid
   lastModified
   url
   title
   resolvedURL
   resolvedTitle
   excerpt
   preview
@@ -66,17 +68,17 @@ const ITEM_BASIC_PROPERTY_NAMES = `
  * Options Objects
  * ---------------
  *
  * Some methods on ReadingList take an "optsList", a variable number of
  * arguments, each of which is an "options object".  Options objects let you
  * control the items that the method acts on.
  *
  * Each options object is a simple object with properties whose names are drawn
- * from ITEM_BASIC_PROPERTY_NAMES.  For an item to match an options object, the
+ * from ITEM_RECORD_PROPERTIES.  For an item to match an options object, the
  * properties of the item must match all the properties in the object.  For
  * example, an object { guid: "123" } matches any item whose GUID is 123.  An
  * object { guid: "123", title: "foo" } matches any item whose GUID is 123 *and*
  * whose title is foo.
  *
  * You can pass multiple options objects as separate arguments.  For an item to
  * match multiple objects, its properties must match all the properties in at
  * least one of the objects.  For example, a list of objects { guid: "123" } and
@@ -84,17 +86,17 @@ const ITEM_BASIC_PROPERTY_NAMES = `
  * foo.
  *
  * The properties in an options object can be arrays, not only scalars.  When a
  * property is an array, then for an item to match, its corresponding property
  * must have a value that matches any value in the array.  For example, an
  * options object { guid: ["123", "456"] } matches any item whose GUID is either
  * 123 *or* 456.
  *
- * In addition to properties with names from ITEM_BASIC_PROPERTY_NAMES, options
+ * In addition to properties with names from ITEM_RECORD_PROPERTIES, options
  * objects can also have the following special properties:
  *
  *   * sort: The name of a property to sort on.
  *   * descending: A boolean, true to sort descending, false to sort ascending.
  *     If `sort` is given but `descending` isn't, the sort is ascending (since
  *     `descending` is falsey).
  *   * limit: Limits the number of matching items to this number.
  *   * offset: Starts matching items at this index in the results.
@@ -104,24 +106,24 @@ const ITEM_BASIC_PROPERTY_NAMES = `
  * really make sense to do so.  The last property in the list is the one that's
  * used.
  *
  * @param store Backing storage for the list.  See SQLiteStore.jsm for what this
  *        object's interface should look like.
  */
 function ReadingListImpl(store) {
   this._store = store;
-  this._itemsByURL = new Map();
+  this._itemsByNormalizedURL = new Map();
   this._iterators = new Set();
   this._listeners = new Set();
 }
 
 ReadingListImpl.prototype = {
 
-  ItemBasicPropertyNames: ITEM_BASIC_PROPERTY_NAMES,
+  ItemRecordProperties: ITEM_RECORD_PROPERTIES,
 
   /**
    * Yields the number of items in the list.
    *
    * @param optsList A variable number of options objects that control the
    *        items that are matched.  See Options Objects.
    * @return Promise<number> The number of matching items in the list.  Rejected
    *         with an Error on error.
@@ -132,30 +134,30 @@ ReadingListImpl.prototype = {
 
   /**
    * Checks whether a given URL is in the ReadingList already.
    *
    * @param {String/nsIURI} url - URL to check.
    * @returns {Promise} Promise that is fulfilled with a boolean indicating
    *                    whether the URL is in the list or not.
    */
-  containsURL: Task.async(function* (url) {
+  hasItemForURL: Task.async(function* (url) {
     url = normalizeURI(url).spec;
 
     // This is used on every tab switch and page load of the current tab, so we
     // want it to be quick and avoid a DB query whenever possible.
 
     // First check if any cached items have a direct match.
-    if (this._itemsByURL.has(url)) {
+    if (this._itemsByNormalizedURL.has(url)) {
       return true;
     }
 
     // Then check if any cached items may have a different resolved URL
     // that matches.
-    for (let itemWeakRef of this._itemsByURL.values()) {
+    for (let itemWeakRef of this._itemsByNormalizedURL.values()) {
       let item = itemWeakRef.get();
       if (item && item.resolvedURL == url) {
         return true;
       }
     }
 
     // Finally, fall back to the DB.
     let count = yield this.count({url: url}, {resolvedURL: url});
@@ -172,20 +174,20 @@ ReadingListImpl.prototype = {
    * @param optsList A variable number of options objects that control the
    *        items that are matched.  See Options Objects.
    * @return Promise<null> Resolved when the enumeration completes *and* the
    *         last promise returned by the callback is resolved.  Rejected with
    *         an Error on error.
    */
   forEachItem: Task.async(function* (callback, ...optsList) {
     let promiseChain = Promise.resolve();
-    yield this._store.forEachItem(obj => {
+    yield this._store.forEachItem(record => {
       promiseChain = promiseChain.then(() => {
         return new Promise((resolve, reject) => {
-          let promise = callback(this._itemFromObject(obj));
+          let promise = callback(this._itemFromRecord(record));
           if (promise instanceof Promise) {
             return promise.then(resolve, reject);
           }
           resolve();
           return undefined;
         });
       });
     }, ...optsList);
@@ -205,33 +207,36 @@ ReadingListImpl.prototype = {
     this._iterators.add(Cu.getWeakReference(iter));
     return iter;
   },
 
   /**
    * Adds an item to the list that isn't already present.
    *
    * The given object represents a new item, and the properties of the object
-   * are those in ITEM_BASIC_PROPERTY_NAMES.  It may have as few or as many
+   * are those in ITEM_RECORD_PROPERTIES.  It may have as few or as many
    * properties that you want to set, but it must have a `url` property.
    *
    * It's an error to call this with an object whose `url` or `guid` properties
    * are the same as those of items that are already present in the list.  The
    * returned promise is rejected in that case.
    *
-   * @param obj A simple object representing an item.
+   * @param record A simple object representing an item.
    * @return Promise<ReadingListItem> Resolved with the new item when the list
    *         is updated.  Rejected with an Error on error.
    */
-  addItem: Task.async(function* (obj) {
-    obj = stripNonItemProperties(obj);
-    normalizeReadingListProperties(obj);
-    yield this._store.addItem(obj);
+  addItem: Task.async(function* (record) {
+    record = normalizeRecord(record);
+    record.addedOn = Date.now();
+    if (Services.prefs.prefHasUserValue("services.sync.client.name")) {
+      record.addedBy = Services.prefs.getCharPref("services.sync.client.name");
+    }
+    yield this._store.addItem(record);
     this._invalidateIterators();
-    let item = this._itemFromObject(obj);
+    let item = this._itemFromRecord(record);
     this._callListeners("onItemAdded", item);
     let mm = Cc["@mozilla.org/globalmessagemanager;1"].getService(Ci.nsIMessageListenerManager);
     mm.broadcastAsyncMessage("Reader:Added", item);
     return item;
   }),
 
   /**
    * Updates the properties of an item that belongs to the list.
@@ -244,17 +249,17 @@ ReadingListImpl.prototype = {
    * The returned promise is rejected in that case.
    *
    * @param item The ReadingListItem to update.
    * @return Promise<null> Resolved when the list is updated.  Rejected with an
    *         Error on error.
    */
   updateItem: Task.async(function* (item) {
     this._ensureItemBelongsToList(item);
-    yield this._store.updateItem(item._properties);
+    yield this._store.updateItem(item._record);
     this._invalidateIterators();
     this._callListeners("onItemUpdated", item);
   }),
 
   /**
    * Deletes an item from the list.  The item must have a `url`.
    *
    * It's an error to call this for an item that doesn't belong to the list.
@@ -263,58 +268,67 @@ ReadingListImpl.prototype = {
    * @param item The ReadingListItem to delete.
    * @return Promise<null> Resolved when the list is updated.  Rejected with an
    *         Error on error.
    */
   deleteItem: Task.async(function* (item) {
     this._ensureItemBelongsToList(item);
     yield this._store.deleteItemByURL(item.url);
     item.list = null;
-    this._itemsByURL.delete(item.url);
+    this._itemsByNormalizedURL.delete(item.url);
     this._invalidateIterators();
     let mm = Cc["@mozilla.org/globalmessagemanager;1"].getService(Ci.nsIMessageListenerManager);
     mm.broadcastAsyncMessage("Reader:Removed", item);
     this._callListeners("onItemDeleted", item);
   }),
 
   /**
+   * Finds the first item that matches the given options.
+   *
+   * @param optsList See Options Objects.
+   * @return The first matching item, or null if there are no matching items.
+   */
+  item: Task.async(function* (...optsList) {
+    return (yield this.iterator(...optsList).items(1))[0] || null;
+  }),
+
+  /**
    * Find any item that matches a given URL - either the item's URL, or its
    * resolved URL.
    *
    * @param {String/nsIURI} uri - URI to match against. This will be normalized.
+   * @return The first matching item, or null if there are no matching items.
    */
-  getItemForURL: Task.async(function* (uri) {
+  itemForURL: Task.async(function* (uri) {
     let url = normalizeURI(uri).spec;
-    let [item] = yield this.iterator({url: url}, {resolvedURL: url}).items(1);
-    return item;
+    return (yield this.item({ url: url }, { resolvedURL: url }));
   }),
 
-   /**
+  /**
    * Add to the ReadingList the page that is loaded in a given browser.
    *
    * @param {<xul:browser>} browser - Browser element for the document,
    * used to get metadata about the article.
    * @param {nsIURI/string} url - url to add to the reading list.
    * @return {Promise} Promise that is fullfilled with the added item.
    */
   addItemFromBrowser: Task.async(function* (browser, url) {
     let metadata = yield getMetadataFromBrowser(browser);
-    let itemData = {
+    let record = {
       url: url,
       title: metadata.title,
       resolvedURL: metadata.url,
       excerpt: metadata.description,
     };
 
     if (metadata.previews.length > 0) {
-      itemData.preview = metadata.previews[0];
+      record.preview = metadata.previews[0];
     }
 
-    let item = yield ReadingList.addItem(itemData);
-    return item;
+    return (yield this.addItem(record));
   }),
 
   /**
    * Adds a listener that will be notified when the list changes.  Listeners
    * are objects with the following optional methods:
    *
    *   onItemAdded(item)
    *   onItemUpdated(item)
@@ -335,56 +349,56 @@ ReadingListImpl.prototype = {
     this._listeners.delete(listener);
   },
 
   /**
    * Call this when you're done with the list.  Don't use it afterward.
    */
   destroy: Task.async(function* () {
     yield this._store.destroy();
-    for (let itemWeakRef of this._itemsByURL.values()) {
+    for (let itemWeakRef of this._itemsByNormalizedURL.values()) {
       let item = itemWeakRef.get();
       if (item) {
         item.list = null;
       }
     }
-    this._itemsByURL.clear();
+    this._itemsByNormalizedURL.clear();
   }),
 
   // The list's backing store.
   _store: null,
 
-  // A Map mapping URL strings to nsIWeakReferences that refer to
+  // A Map mapping *normalized* URL strings to nsIWeakReferences that refer to
   // ReadingListItems.
-  _itemsByURL: null,
+  _itemsByNormalizedURL: null,
 
   // A Set containing nsIWeakReferences that refer to valid iterators produced
   // by the list.
   _iterators: null,
 
   // A Set containing listener objects.
   _listeners: null,
 
   /**
-   * Returns the ReadingListItem represented by the given simple object.  If
+   * Returns the ReadingListItem represented by the given record object.  If
    * the item doesn't exist yet, it's created first.
    *
-   * @param obj A simple object with item properties.
+   * @param record A simple object with *normalized* item record properties.
    * @return The ReadingListItem.
    */
-  _itemFromObject(obj) {
-    let itemWeakRef = this._itemsByURL.get(obj.url);
+  _itemFromRecord(record) {
+    let itemWeakRef = this._itemsByNormalizedURL.get(record.url);
     let item = itemWeakRef ? itemWeakRef.get() : null;
     if (item) {
-      item.setProperties(obj, false);
+      item._record = record;
     }
     else {
-      item = new ReadingListItem(obj);
+      item = new ReadingListItem(record);
       item.list = this;
-      this._itemsByURL.set(obj.url, Cu.getWeakReference(item));
+      this._itemsByNormalizedURL.set(record.url, Cu.getWeakReference(item));
     }
     return item;
   },
 
   /**
    * Marks all the list's iterators as invalid, meaning it's not safe to use
    * them anymore.
    */
@@ -420,343 +434,316 @@ ReadingListImpl.prototype = {
   _ensureItemBelongsToList(item) {
     if (!item || !item._ensureBelongsToList) {
       throw new Error("The item is not a ReadingListItem");
     }
     item._ensureBelongsToList();
   },
 };
 
-/*
- * normalize the properties of a "regular" object that reflects a ReadingListItem
- */
-function normalizeReadingListProperties(obj) {
-  if (obj.url) {
-    obj.url = normalizeURI(obj.url).spec;
-  }
-  if (obj.resolvedURL) {
-    obj.resolvedURL = normalizeURI(obj.resolvedURL).spec;
-  }
-}
-
 
 let _unserializable = () => {}; // See comments in the ReadingListItem ctor.
 
 /**
  * An item in a reading list.
  *
  * Each item belongs to a list, and it's an error to use an item with a
  * ReadingList that the item doesn't belong to.
  *
- * @param props The properties of the item, as few or many as you want.
+ * @param record A simple object with the properties of the item, as few or many
+ *        as you want.  This will be normalized.
  */
-function ReadingListItem(props={}) {
-  this._properties = {};
+function ReadingListItem(record={}) {
+  this._record = record;
 
   // |this._unserializable| works around a problem when sending one of these
   // items via a message manager. If |this.list| is set, the item can't be
   // transferred directly, so .toJSON is implicitly called and the object
   // returned via that is sent. However, once the item is deleted and |this.list|
   // is null, the item *can* be directly serialized - so the message handler
-  // sees the "raw" object - ie, it sees "_properties" etc.
+  // sees the "raw" object - ie, it sees "_record" etc.
   // We work around this problem by *always* having an unserializable property
   // on the object - this way the implicit .toJSON call is always made, even
   // when |this.list| is null.
   this._unserializable = _unserializable;
-
-  this.setProperties(props, false);
 }
 
 ReadingListItem.prototype = {
 
+  // Be careful when caching properties.  If you cache a property that depends
+  // on a mutable _record property, then you need to recache your property after
+  // _record is set.
+
   /**
    * Item's unique ID.
    * @type string
    */
   get id() {
     if (!this._id) {
       this._id = hash(this.url);
     }
     return this._id;
   },
 
   /**
    * The item's server-side GUID. This is set by the remote server and therefore is not
-   * guarenteed to be set for local items.
+   * guaranteed to be set for local items.
    * @type string
    */
   get guid() {
-    return this._properties.guid || undefined;
-  },
-  set guid(val) {
-    this._properties.guid = val;
-  },
-
-  /**
-   * The date the item was last modified.
-   * @type Date
-   */
-  get lastModified() {
-    return this._properties.lastModified ?
-           new Date(this._properties.lastModified) :
-           undefined;
-  },
-  set lastModified(val) {
-    this._properties.lastModified = val.valueOf();
+    return this._record.guid || undefined;
   },
 
   /**
    * The item's URL.
    * @type string
    */
   get url() {
-    return this._properties.url;
-  },
-  set url(val) {
-    this._properties.url = normalizeURI(val).spec;
+    return this._record.url;
   },
 
   /**
    * The item's URL as an nsIURI.
    * @type nsIURI
    */
   get uri() {
-    return this._properties.url ?
-           Services.io.newURI(this._properties.url, "", null) :
-           undefined;
-  },
-  set uri(val) {
-    this.url = normalizeURI(val).spec;
-  },
-
-  /**
-   * Returns the domain (a string) of the item's URL.  If the URL doesn't have a
-   * domain, then the URL itself (also a string) is returned.
-   */
-  get domain() {
-    try {
-      return this.uri.host;
+    if (!this._uri) {
+      this._uri = this._record.url ?
+                  Services.io.newURI(this._record.url, "", null) :
+                  undefined;
     }
-    catch (err) {}
-    return this.url;
+    return this._uri;
   },
 
   /**
    * The item's resolved URL.
    * @type string
    */
   get resolvedURL() {
-    return this._properties.resolvedURL;
+    return this._record.resolvedURL;
   },
   set resolvedURL(val) {
-    this._properties.resolvedURL = normalizeURI(val).spec;
+    this._updateRecord({ resolvedURL: val });
   },
 
   /**
-   * The item's resolved URL as an nsIURI.
+   * The item's resolved URL as an nsIURI.  The setter takes an nsIURI or a
+   * string spec.
    * @type nsIURI
    */
   get resolvedURI() {
-    return this._properties.resolvedURL ?
-           Services.io.newURI(this._properties.resolvedURL, "", null) :
+    return this._record.resolvedURL ?
+           Services.io.newURI(this._record.resolvedURL, "", null) :
            undefined;
   },
   set resolvedURI(val) {
-    this.resolvedURL = val.spec;
+    this._updateRecord({ resolvedURL: val });
   },
 
   /**
    * The item's title.
    * @type string
    */
   get title() {
-    return this._properties.title;
+    return this._record.title;
   },
   set title(val) {
-    this._properties.title = val;
+    this._updateRecord({ title: val });
   },
 
   /**
    * The item's resolved title.
    * @type string
    */
   get resolvedTitle() {
-    return this._properties.resolvedTitle;
+    return this._record.resolvedTitle;
   },
   set resolvedTitle(val) {
-    this._properties.resolvedTitle = val;
+    this._updateRecord({ resolvedTitle: val });
   },
 
   /**
    * The item's excerpt.
    * @type string
    */
   get excerpt() {
-    return this._properties.excerpt;
+    return this._record.excerpt;
   },
   set excerpt(val) {
-    this._properties.excerpt = val;
+    this._updateRecord({ excerpt: val });
   },
 
   /**
    * The item's status.
    * @type integer
    */
   get status() {
-    return this._properties.status;
+    return this._record.status;
   },
   set status(val) {
-    this._properties.status = val;
+    this._updateRecord({ status: val });
   },
 
   /**
    * Whether the item is a favorite.
    * @type boolean
    */
   get favorite() {
-    return !!this._properties.favorite;
+    return !!this._record.favorite;
   },
   set favorite(val) {
-    this._properties.favorite = !!val;
+    this._updateRecord({ favorite: !!val });
   },
 
   /**
    * Whether the item is an article.
    * @type boolean
    */
   get isArticle() {
-    return !!this._properties.isArticle;
+    return !!this._record.isArticle;
   },
   set isArticle(val) {
-    this._properties.isArticle = !!val;
+    this._updateRecord({ isArticle: !!val });
   },
 
   /**
    * The item's word count.
    * @type integer
    */
   get wordCount() {
-    return this._properties.wordCount;
+    return this._record.wordCount;
   },
   set wordCount(val) {
-    this._properties.wordCount = val;
+    this._updateRecord({ wordCount: val });
   },
 
   /**
    * Whether the item is unread.
    * @type boolean
    */
   get unread() {
-    return !!this._properties.unread;
+    return !!this._record.unread;
   },
   set unread(val) {
-    this._properties.unread = !!val;
+    this._updateRecord({ unread: !!val });
   },
 
   /**
    * The date the item was added.
    * @type Date
    */
   get addedOn() {
-    return this._properties.addedOn ?
-           new Date(this._properties.addedOn) :
+    return this._record.addedOn ?
+           new Date(this._record.addedOn) :
            undefined;
   },
   set addedOn(val) {
-    this._properties.addedOn = val.valueOf();
+    this._updateRecord({ addedOn: val.valueOf() });
   },
 
   /**
    * The date the item was stored.
    * @type Date
    */
   get storedOn() {
-    return this._properties.storedOn ?
-           new Date(this._properties.storedOn) :
+    return this._record.storedOn ?
+           new Date(this._record.storedOn) :
            undefined;
   },
   set storedOn(val) {
-    this._properties.storedOn = val.valueOf();
+    this._updateRecord({ storedOn: val.valueOf() });
   },
 
   /**
    * The GUID of the device that marked the item read.
    * @type string
    */
   get markedReadBy() {
-    return this._properties.markedReadBy;
+    return this._record.markedReadBy;
   },
   set markedReadBy(val) {
-    this._properties.markedReadBy = val;
+    this._updateRecord({ markedReadBy: val });
   },
 
   /**
    * The date the item marked read.
    * @type Date
    */
   get markedReadOn() {
-    return this._properties.markedReadOn ?
-           new Date(this._properties.markedReadOn) :
+    return this._record.markedReadOn ?
+           new Date(this._record.markedReadOn) :
            undefined;
   },
   set markedReadOn(val) {
-    this._properties.markedReadOn = val.valueOf();
+    this._updateRecord({ markedReadOn: val.valueOf() });
   },
 
   /**
    * The item's read position.
    * @param integer
    */
   get readPosition() {
-    return this._properties.readPosition;
+    return this._record.readPosition;
   },
   set readPosition(val) {
-    this._properties.readPosition = val;
+    this._updateRecord({ readPosition: val });
   },
 
   /**
    * The URL to a preview image.
    * @type string
    */
    get preview() {
-     return this._properties.preview;
+     return this._record.preview;
    },
 
   /**
-   * Sets the given properties of the item, optionally calling list.updateItem().
-   *
-   * @param props A simple object containing the properties to set.
-   * @param update If true, updateItem() is called for this item.
-   * @return Promise<null> If update is true, resolved when the update
-   *         completes; otherwise resolved immediately.
-   */
-  setProperties: Task.async(function* (props, update=true) {
-    for (let name in props) {
-      this._properties[name] = props[name];
-    }
-    // make sure everything is normalized.
-    normalizeReadingListProperties(this._properties);
-    if (update) {
-      yield this.list.updateItem(this);
-    }
-  }),
-
-  /**
    * Deletes the item from its list.
    *
    * @return Promise<null> Resolved when the list has been updated.
    */
   delete: Task.async(function* () {
     this._ensureBelongsToList();
     yield this.list.deleteItem(this);
     this.delete = () => Promise.reject("The item has already been deleted");
   }),
 
   toJSON() {
-    return this._properties;
+    return this._record;
+  },
+
+  /**
+   * Do not use this at all unless you know what you're doing.  Use the public
+   * getters and setters, above, instead.
+   *
+   * A simple object that contains the item's normalized data in the same format
+   * that the local store and server use.  Records passed in by the consumer are
+   * not normalized, but everywhere else, records are always normalized unless
+   * otherwise stated.  The setter normalizes the passed-in value, so it will
+   * throw an error if the value is not a valid record.
+   */
+  get _record() {
+    return this.__record;
+  },
+  set _record(val) {
+    this.__record = normalizeRecord(val);
+  },
+
+  /**
+   * Updates the item's record.  This calls the _record setter, so it will throw
+   * an error if the partial record is not valid.
+   *
+   * @param partialRecord An object containing any of the record properties.
+   */
+  _updateRecord(partialRecord) {
+    let record = this._record;
+    for (let prop in partialRecord) {
+      record[prop] = partialRecord[prop];
+    }
+    this._record = record;
   },
 
   _ensureBelongsToList() {
     if (!this.list) {
       throw new Error("The item must belong to a reading list");
     }
   },
 };
@@ -849,16 +836,46 @@ ReadingListItemIterator.prototype = {
 
   _ensureValid() {
     if (this.invalid) {
       throw new Error("The iterator has been invalidated");
     }
   },
 };
 
+
+/**
+ * Normalizes the properties of a record object, which represents a
+ * ReadingListItem.  Throws an error if the record contains properties that
+ * aren't in ITEM_RECORD_PROPERTIES.
+ *
+ * @param record A non-normalized record object.
+ * @return The new normalized record.
+ */
+function normalizeRecord(nonNormalizedRecord) {
+  let record = {};
+  for (let prop in nonNormalizedRecord) {
+    if (!ITEM_RECORD_PROPERTIES.includes(prop)) {
+      throw new Error("Unrecognized item property: " + prop);
+    }
+    switch (prop) {
+    case "url":
+    case "resolvedURL":
+      if (nonNormalizedRecord[prop]) {
+        record[prop] = normalizeURI(nonNormalizedRecord[prop]).spec;
+      }
+      break;
+    default:
+      record[prop] = nonNormalizedRecord[prop];
+      break;
+    }
+  }
+  return record;
+}
+
 /**
  * Normalize a URI, stripping away extraneous parts we don't want to store
  * or compare against.
  *
  * @param {nsIURI/String} uri - URI to normalize.
  * @returns {nsIURI} Cloned and normalized version of the input URI.
  */
 function normalizeURI(uri) {
@@ -867,26 +884,16 @@ function normalizeURI(uri) {
   }
   uri = uri.cloneIgnoringRef();
   try {
     uri.userPass = "";
   } catch (ex) {} // Not all nsURI impls (eg, nsSimpleURI) support .userPass
   return uri;
 };
 
-function stripNonItemProperties(item) {
-  let obj = {};
-  for (let name of ITEM_BASIC_PROPERTY_NAMES) {
-    if (name in item) {
-      obj[name] = item[name];
-    }
-  }
-  return obj;
-}
-
 function hash(str) {
   let hasher = Cc["@mozilla.org/security/hash;1"].
                createInstance(Ci.nsICryptoHash);
   hasher.init(Ci.nsICryptoHash.MD5);
   let stream = Cc["@mozilla.org/io/string-input-stream;1"].
                createInstance(Ci.nsIStringInputStream);
   stream.data = str;
   hasher.updateFromStream(stream, -1);
--- a/browser/components/readinglist/SQLiteStore.jsm
+++ b/browser/components/readinglist/SQLiteStore.jsm
@@ -57,26 +57,26 @@ this.SQLiteStore.prototype = {
    *        single object, an item.
    * @param optsList A variable number of options objects that control the
    *        items that are matched.  See Options Objects in ReadingList.jsm.
    * @return Promise<null> Resolved when the enumeration completes.  Rejected
    *         with an Error on error.
    */
   forEachItem: Task.async(function* (callback, ...optsList) {
     let [sql, args] = sqlFromOptions(optsList);
-    let colNames = ReadingList.ItemBasicPropertyNames;
+    let colNames = ReadingList.ItemRecordProperties;
     let conn = yield this._connectionPromise;
     yield conn.executeCached(`
       SELECT ${colNames} FROM items ${sql};
     `, args, row => callback(itemFromRow(row)));
   }),
 
   /**
    * Adds an item to the store that isn't already present.  See
-   * ReadingList.prototype.addItems.
+   * ReadingList.prototype.addItem.
    *
    * @param items A simple object representing an item.
    * @return Promise<null> Resolved when the store is updated.  Rejected with an
    *         Error on error.
    */
   addItem: Task.async(function* (item) {
     let colNames = [];
     let paramNames = [];
@@ -214,24 +214,24 @@ this.SQLiteStore.prototype = {
     yield conn.execute(`
       CREATE INDEX items_unread ON items (unread);
     `);
   }),
 };
 
 /**
  * Returns a simple object whose properties are the
- * ReadingList.ItemBasicPropertyNames properties lifted from the given row.
+ * ReadingList.ItemRecordProperties lifted from the given row.
  *
  * @param row A mozIStorageRow.
  * @return The item.
  */
 function itemFromRow(row) {
   let item = {};
-  for (let name of ReadingList.ItemBasicPropertyNames) {
+  for (let name of ReadingList.ItemRecordProperties) {
     item[name] = row.getResultByName(name);
   }
   return item;
 }
 
 /**
  * Returns the back part of a SELECT statement generated from the given list of
  * options.
--- a/browser/components/readinglist/sidebar.js
+++ b/browser/components/readinglist/sidebar.js
@@ -56,19 +56,23 @@ let RLSidebar = {
     this.list = document.getElementById("list");
     this.emptyListInfo = document.getElementById("emptyListInfo");
     this.itemTemplate = document.getElementById("item-template");
 
     this.list.addEventListener("click", event => this.onListClick(event));
     this.list.addEventListener("mousemove", event => this.onListMouseMove(event));
     this.list.addEventListener("keydown", event => this.onListKeyDown(event), true);
 
+    window.addEventListener("message", event => this.onMessage(event));
+
     this.listPromise = this.ensureListItems();
     ReadingList.addListener(this);
 
+    Services.prefs.setBoolPref("browser.readinglist.sidebarEverOpened", true);
+
     let initEvent = new CustomEvent("Initialized", {bubbles: true});
     document.documentElement.dispatchEvent(initEvent);
   },
 
   /**
    * Un-initialize the sidebar UI.
    */
   uninit() {
@@ -79,22 +83,27 @@ let RLSidebar = {
 
   /**
    * Handle an item being added to the ReadingList.
    * TODO: We may not want to show this new item right now.
    * TODO: We should guard against the list growing here.
    *
    * @param {ReadinglistItem} item - Item that was added.
    */
-  onItemAdded(item) {
+  onItemAdded(item, append = false) {
     log.trace(`onItemAdded: ${item}`);
 
     let itemNode = document.importNode(this.itemTemplate.content, true).firstElementChild;
     this.updateItem(item, itemNode);
-    this.list.appendChild(itemNode);
+    // XXX Inserting at the top by default is a temp hack that will stop
+    // working once we start including items received from sync.
+    if (append)
+      this.list.appendChild(itemNode);
+    else
+      this.list.insertBefore(itemNode, this.list.firstChild);
     this.itemNodesById.set(item.id, itemNode);
     this.itemsById.set(item.id, item);
 
     this.emptyListInfo.hidden = true;
   },
 
   /**
    * Handle an item being deleted from the ReadingList.
@@ -133,37 +142,44 @@ let RLSidebar = {
    * @param {ReadingListItem} item - Item to use as a source.
    * @param {Element} itemNode - Element to update.
    */
   updateItem(item, itemNode) {
     itemNode.setAttribute("id", "item-" + item.id);
     itemNode.setAttribute("title", `${item.title}\n${item.url}`);
 
     itemNode.querySelector(".item-title").textContent = item.title;
-    itemNode.querySelector(".item-domain").textContent = item.domain;
+
+    let domain = item.uri.spec;
+    try {
+      domain = item.uri.host;
+    }
+    catch (err) {}
+    itemNode.querySelector(".item-domain").textContent = domain;
+
     let thumb = itemNode.querySelector(".item-thumb-container");
     if (item.preview) {
       thumb.style.backgroundImage = "url(" + item.preview + ")";
     } else {
       thumb.style.removeProperty("background-image");
     }
   },
 
   /**
    * Ensure that the list is populated with the correct items.
    */
   ensureListItems: Task.async(function* () {
     yield ReadingList.forEachItem(item => {
       // TODO: Should be batch inserting via DocumentFragment
       try {
-        this.onItemAdded(item);
+        this.onItemAdded(item, true);
       } catch (e) {
         log.warn("Error adding item", e);
       }
-    });
+    }, {sort: "addedOn", descending: true});
     this.emptyListInfo.hidden = (this.numItems > 0);
   }),
 
   /**
    * Get the number of items currently displayed in the list.
    * @type {number}
    */
   get numItems() {
@@ -181,24 +197,18 @@ let RLSidebar = {
   set activeItem(node) {
     if (node && node.parentNode != this.list) {
       log.error(`Unable to set activeItem to invalid node ${node}`);
       return;
     }
 
     log.debug(`Setting activeItem: ${node ? node.id : null}`);
 
-    if (node) {
-      if (!node.classList.contains("selected")) {
-        this.selectedItem = node;
-      }
-
-      if (node.classList.contains("active")) {
-        return;
-      }
+    if (node && node.classList.contains("active")) {
+      return;
     }
 
     let prevItem = document.querySelector("#list > .item.active");
     if (prevItem) {
       prevItem.classList.remove("active");
     }
 
     if (node) {
@@ -411,12 +421,32 @@ let RLSidebar = {
     } else if (event.keyCode == KeyEvent.DOM_VK_RETURN) {
       let selectedItem = this.selectedItem;
       if (selectedItem) {
         this.activeItem = this.selectedItem;
         this.openActiveItem(event);
       }
     }
   },
+
+  /**
+   * Handle a message, typically sent from browser-readinglist.js
+   * @param {Event} event - Triggering event.
+   */
+  onMessage(event) {
+    let msg = event.data;
+
+    if (msg.topic != "UpdateActiveItem") {
+      return;
+    }
+
+    if (!msg.url) {
+      this.activeItem = null;
+    } else {
+      ReadingList.itemForURL(msg.url).then(item => {
+        this.activeItem = this.itemNodesById.get(item.id);
+      });
+    }
+  }
 };
 
 
 addEventListener("DOMContentLoaded", () => RLSidebar.init());
--- a/browser/components/readinglist/test/ReadingListTestUtils.jsm
+++ b/browser/components/readinglist/test/ReadingListTestUtils.jsm
@@ -79,17 +79,23 @@ SidebarUtils.prototype = {
     this.Assert.ok(node.classList.contains("item"),
                    "Node should have .item class");
     this.Assert.equal(node.id, "item-" + item.id,
                       "Node should have correct ID");
     this.Assert.equal(node.getAttribute("title"), item.title + "\n" + item.url.spec,
                       "Node should have correct title attribute");
     this.Assert.equal(node.querySelector(".item-title").textContent, item.title,
                       "Node's title element's text should match item title");
-    this.Assert.equal(node.querySelector(".item-domain").textContent, item.domain,
+
+    let domain = item.uri.spec;
+    try {
+      domain = item.uri.host;
+    }
+    catch (err) {}
+    this.Assert.equal(node.querySelector(".item-domain").textContent, domain,
                       "Node's domain element's text should match item title");
   },
 
   expectSelectedId(itemId) {
     let selectedItem = this.RLSidebar.selectedItem;
     if (itemId == null) {
       this.Assert.equal(selectedItem, null, "Should have no selected item");
     } else {
--- a/browser/components/readinglist/test/xpcshell/test_ReadingList.js
+++ b/browser/components/readinglist/test/xpcshell/test_ReadingList.js
@@ -27,24 +27,22 @@ add_task(function* prepare() {
     if (gDBFile.exists()) {
       gDBFile.remove(true);
     }
   });
 
   gItems = [];
   for (let i = 0; i < 3; i++) {
     gItems.push({
-      list: gList,
       guid: `guid${i}`,
       url: `http://example.com/${i}`,
       resolvedURL: `http://example.com/resolved/${i}`,
       title: `title ${i}`,
       excerpt: `excerpt ${i}`,
       unread: 0,
-      addedOn: Date.now(),
       lastModified: Date.now(),
       favorite: 0,
       isArticle: 1,
       storedOn: Date.now(),
     });
   }
 
   for (let item of gItems) {
@@ -58,37 +56,33 @@ add_task(function* item_properties() {
   let iter = gList.iterator({
     sort: "guid",
   });
   let item = (yield iter.items(1))[0];
   Assert.ok(item);
 
   Assert.ok(item.uri);
   Assert.ok(item.uri instanceof Ci.nsIURI);
-  Assert.equal(item.uri.spec, item.url);
+  Assert.equal(item.uri.spec, item._record.url);
 
   Assert.ok(item.resolvedURI);
   Assert.ok(item.resolvedURI instanceof Ci.nsIURI);
-  Assert.equal(item.resolvedURI.spec, item.resolvedURL);
-
-  Assert.ok(item.lastModified);
-  Assert.ok(item.lastModified instanceof Cu.getGlobalForObject(ReadingList).Date);
+  Assert.equal(item.resolvedURI.spec, item._record.resolvedURL);
 
   Assert.ok(item.addedOn);
   Assert.ok(item.addedOn instanceof Cu.getGlobalForObject(ReadingList).Date);
 
   Assert.ok(item.storedOn);
   Assert.ok(item.storedOn instanceof Cu.getGlobalForObject(ReadingList).Date);
 
   Assert.ok(typeof(item.favorite) == "boolean");
   Assert.ok(typeof(item.isArticle) == "boolean");
   Assert.ok(typeof(item.unread) == "boolean");
 
-  Assert.equal(item.domain, "example.com");
-  Assert.equal(item.id, hash(item.url));
+  Assert.equal(item.id, hash(item._record.url));
 });
 
 add_task(function* constraints() {
   // add an item again
   let err = null;
   try {
     yield gList.addItem(gItems[0]);
   }
@@ -116,52 +110,54 @@ add_task(function* constraints() {
   try {
     yield gList.addItem(item);
   }
   catch (e) {
     err = e;
   }
   checkError(err);
 
-  // update an item with an existing url
-  let rlitem = yield gList.getItemForURL(gItems[0].url);
-  rlitem.guid = gItems[1].guid;
-  err = null;
-  try {
-    yield gList.updateItem(rlitem);
-  }
-  catch (e) {
-    err = e;
-  }
-  checkError(err);
-
   // add a new item with an existing resolvedURL
   item = kindOfClone(gItems[0]);
   item.resolvedURL = gItems[0].resolvedURL;
   err = null;
   try {
     yield gList.addItem(item);
   }
   catch (e) {
     err = e;
   }
   checkError(err);
 
-  // update an item with an existing resolvedURL
-  rlitem = yield gList.getItemForURL(gItems[0].url);
-  rlitem.url = gItems[1].url;
+  // add a new item with no url
+  item = kindOfClone(gItems[0]);
+  delete item.url;
   err = null;
   try {
-    yield gList.updateItem(rlitem);
+    yield gList.addItem(item);
   }
   catch (e) {
     err = e;
   }
   checkError(err);
 
+  // add an item with a bogus property
+  item = kindOfClone(gItems[0]);
+  item.bogus = "gnarly";
+  err = null;
+  try {
+    yield gList.addItem(item);
+  }
+  catch (e) {
+    err = e;
+  }
+  Assert.ok(err);
+  Assert.ok(err.message);
+  Assert.ok(err.message.indexOf("Unrecognized item property:") >= 0);
+
   // add a new item with no guid, which is allowed
   item = kindOfClone(gItems[0]);
   delete item.guid;
   err = null;
   let rlitem1;
   try {
     rlitem1 = yield gList.addItem(item);
   }
@@ -178,34 +174,23 @@ add_task(function* constraints() {
   try {
     rlitem2 = yield gList.addItem(item);
   }
   catch (e) {
     err = e;
   }
   Assert.ok(!err, err ? err.message : undefined);
 
-  // Delete both items since other tests assume the store contains only gItems.
+  // Delete the two previous items since other tests assume the store contains
+  // only gItems.
   yield gList.deleteItem(rlitem1);
   yield gList.deleteItem(rlitem2);
   let items = [];
-  yield gList.forEachItem(i => items.push(i), { url: [rlitem1.url, rlitem2.url] });
+  yield gList.forEachItem(i => items.push(i), { url: [rlitem1.uri.spec, rlitem2.uri.spec] });
   Assert.equal(items.length, 0);
-
-  // add a new item with no url
-  item = kindOfClone(gItems[0]);
-  delete item.url;
-  err = null;
-  try {
-    yield gList.addItem(item);
-  }
-  catch (e) {
-    err = e;
-  }
-  checkError(err);
 });
 
 add_task(function* count() {
   let count = yield gList.count();
   Assert.equal(count, gItems.length);
 
   count = yield gList.count({
     guid: gItems[0].guid,
@@ -501,16 +486,32 @@ add_task(function* iterator_forEach_prom
       }, 0);
     });
     promises.push(promise);
     return promise;
   });
   checkItems(items, gItems);
 });
 
+add_task(function* item() {
+  let item = yield gList.item({ guid: gItems[0].guid });
+  checkItems([item], [gItems[0]]);
+
+  item = yield gList.item({ guid: gItems[1].guid });
+  checkItems([item], [gItems[1]]);
+});
+
+add_task(function* itemForURL() {
+  let item = yield gList.itemForURL(gItems[0].url);
+  checkItems([item], [gItems[0]]);
+
+  item = yield gList.itemForURL(gItems[1].url);
+  checkItems([item], [gItems[1]]);
+});
+
 add_task(function* updateItem() {
   // get an item
   let items = [];
   yield gList.forEachItem(i => items.push(i), {
     guid: gItems[0].guid,
   });
   Assert.equal(items.length, 1);
   let item = items[0];
@@ -526,62 +527,75 @@ add_task(function* updateItem() {
   yield gList.forEachItem(i => items.push(i), {
     guid: gItems[0].guid,
   });
   Assert.equal(items.length, 1);
   item = items[0];
   Assert.equal(item.title, newTitle);
 });
 
-add_task(function* item_setProperties() {
+add_task(function* item_setRecord() {
   // get an item
   let iter = gList.iterator({
     sort: "guid",
   });
   let item = (yield iter.items(1))[0];
   Assert.ok(item);
 
-  // item.setProperties(update=false).  After fetching the item again, its title
-  // should be the old title.
+  // Set item._record without an updateItem.  After fetching the item again, its
+  // title should be the old title.
   let oldTitle = item.title;
-  let newTitle = "item_setProperties title 1";
+  let newTitle = "item_setRecord title 1";
   Assert.notEqual(oldTitle, newTitle);
-  item.setProperties({ title: newTitle }, false);
+  item._record.title = newTitle;
   Assert.equal(item.title, newTitle);
   iter = gList.iterator({
     sort: "guid",
   });
   let sameItem = (yield iter.items(1))[0];
   Assert.ok(item === sameItem);
   Assert.equal(sameItem.title, oldTitle);
 
-  // item.setProperties(update=true).  After fetching the item again, its title
-  // should be the new title.
-  newTitle = "item_setProperties title 2";
-  item.setProperties({ title: newTitle }, true);
+  // Set item._record followed by an updateItem.  After fetching the item again,
+  // its title should be the new title.
+  newTitle = "item_setRecord title 2";
+  item._record.title = newTitle;
+  yield gList.updateItem(item);
   Assert.equal(item.title, newTitle);
   iter = gList.iterator({
     sort: "guid",
   });
   sameItem = (yield iter.items(1))[0];
   Assert.ok(item === sameItem);
   Assert.equal(sameItem.title, newTitle);
 
-  // Set item.title directly.  After fetching the item again, its title should
-  // be the new title.
-  newTitle = "item_setProperties title 3";
+  // Set item.title directly and call updateItem.  After fetching the item
+  // again, its title should be the new title.
+  newTitle = "item_setRecord title 3";
   item.title = newTitle;
-  gList.updateItem(item);
+  yield gList.updateItem(item);
   Assert.equal(item.title, newTitle);
   iter = gList.iterator({
     sort: "guid",
   });
   sameItem = (yield iter.items(1))[0];
   Assert.ok(item === sameItem);
   Assert.equal(sameItem.title, newTitle);
+
+  // Setting _record to an object with a bogus property should throw.
+  let err = null;
+  try {
+    item._record = { bogus: "gnarly" };
+  }
+  catch (e) {
+    err = e;
+  }
+  Assert.ok(err);
+  Assert.ok(err.message);
+  Assert.ok(err.message.indexOf("Unrecognized item property:") >= 0);
 });
 
 add_task(function* listeners() {
   Assert.equal((yield gList.count()), gItems.length);
   // add an item
   let resolve;
   let listenerPromise = new Promise(r => resolve = r);
   let listener = {
@@ -597,17 +611,17 @@ add_task(function* listeners() {
 
   // update an item
   listenerPromise = new Promise(r => resolve = r);
   listener = {
     onItemUpdated: resolve,
   };
   gList.addListener(listener);
   items[0].title = "listeners new title";
-  gList.updateItem(items[0]);
+  yield gList.updateItem(items[0]);
   let listenerItem = yield listenerPromise;
   Assert.ok(listenerItem);
   Assert.ok(listenerItem === items[0]);
   gList.removeListener(listener);
   Assert.equal((yield gList.count()), gItems.length + 1);
 
   // delete an item
   listenerPromise = new Promise(r => resolve = r);
@@ -661,21 +675,20 @@ add_task(function* deleteItem() {
   checkItems(items, gItems.slice(3));
 });
 
 function checkItems(actualItems, expectedItems) {
   Assert.equal(actualItems.length, expectedItems.length);
   for (let i = 0; i < expectedItems.length; i++) {
     for (let prop in expectedItems[i]) {
       if (prop != "list") {
-        Assert.ok(prop in actualItems[i]._properties, prop);
-        Assert.equal(actualItems[i]._properties[prop], expectedItems[i][prop]);
+        Assert.ok(prop in actualItems[i]._record, prop);
+        Assert.equal(actualItems[i]._record[prop], expectedItems[i][prop]);
       }
     }
-    Assert.equal(actualItems[i].list, expectedItems[i].list);
   }
 }
 
 function checkError(err) {
   Assert.ok(err);
   Assert.ok(err instanceof Cu.getGlobalForObject(Sqlite).Error, err);
 }
 
--- a/browser/locales/en-US/chrome/browser/newTab.dtd
+++ b/browser/locales/en-US/chrome/browser/newTab.dtd
@@ -1,16 +1,16 @@
 <!-- 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/. -->
 
 <!-- These strings are used in the about:newtab page -->
 <!ENTITY newtab.pageTitle "New Tab">
 <!ENTITY newtab.customize.title "Customize your New Tab page">
-<!ENTITY newtab.customize.enhanced "Enhanced">
-<!ENTITY newtab.customize.classic "Classic">
-<!ENTITY newtab.customize.blank "Blank">
+<!ENTITY newtab.customize.suggested "Show suggested and your top sites">
+<!ENTITY newtab.customize.topsites "Show your top sites">
+<!ENTITY newtab.customize.blank2 "Show blank page">
 <!ENTITY newtab.customize.what "What is this page?">
 <!ENTITY newtab.intro.header "What is this page?">
 <!ENTITY newtab.undo.removedLabel "Thumbnail removed.">
 <!ENTITY newtab.undo.undoButton "Undo.">
 <!ENTITY newtab.undo.restoreButton "Restore All.">
 <!ENTITY newtab.undo.closeTooltip "Hide">
--- a/browser/locales/en-US/chrome/browser/newTab.properties
+++ b/browser/locales/en-US/chrome/browser/newTab.properties
@@ -4,16 +4,21 @@
 
 newtab.pin=Pin this site at its current position
 newtab.unpin=Unpin this site
 newtab.block=Remove this site
 # LOCALIZATION NOTE(newtab.sponsored.button): This text appears for sponsored
 # and enhanced tiles on the same line as the tile's title, so prefer short
 # strings to avoid overlap. This string should be uppercase.
 newtab.sponsored.button=SPONSORED
+# LOCALIZATION NOTE(newtab.suggested.button): %1$S will be replaced inline by
+# one of the user's top 100 sites that triggered this suggested tile.
+# This text appears for suggested tiles under the tile's title, so prefer short
+# strings to avoid truncating important text.
+newtab.suggested.button=Suggested for %1$S visitors
 # LOCALIZATION NOTE(newtab.sponsored.explain): %1$S will be replaced inline by
 # the (X) block icon. %2$S will be replaced by an active link using string
 # newtab.learn.link as text.
 newtab.sponsored.explain=This tile is being shown to you on behalf of a Mozilla partner. You can remove it at any time by clicking the %1$S button. %2$S
 # LOCALIZATION NOTE(newtab.enhanced.explain): %1$S will be replaced inline by
 # the gear icon used to customize the new tab window. %2$S will be replaced by
 # an active link using string newtab.learn.link as text.
 newtab.enhanced.explain=A Mozilla partner has visually enhanced this tile, replacing the screenshot. You can turn off enhanced tiles by clicking the %1$S button for your preferences. %2$S
--- a/browser/modules/DirectoryLinksProvider.jsm
+++ b/browser/modules/DirectoryLinksProvider.jsm
@@ -558,34 +558,49 @@ let DirectoryLinksProvider = {
     }
 
     // Create a flat list of all possible links we can show as related.
     // Note that many top sites may map to the same related links, but we only
     // want to count each related link once (based on url), thus possibleLinks is a map
     // from url to relatedLink. Thus, each link has an equal chance of being chosen at
     // random from flattenedLinks if it appears only once.
     let possibleLinks = new Map();
+    let targetedSites = new Map();
     this._topSitesWithRelatedLinks.forEach(topSiteWithRelatedLink => {
       let relatedLinksMap = this._relatedLinks.get(topSiteWithRelatedLink);
       relatedLinksMap.forEach((relatedLink, url) => {
         possibleLinks.set(url, relatedLink);
+
+        // Keep a map of URL to targeted sites. We later use this to show the user
+        // what site they visited to trigger this suggestion.
+        if (!targetedSites.get(url)) {
+          targetedSites.set(url, []);
+        }
+        targetedSites.get(url).push(topSiteWithRelatedLink);
       })
     });
     let flattenedLinks = [...possibleLinks.values()];
 
     // Choose our related link at random
     let relatedIndex = Math.floor(Math.random() * flattenedLinks.length);
     let chosenRelatedLink = flattenedLinks[relatedIndex];
 
     // Show the new directory tile.
     this._callObservers("onLinkChanged", {
       url: chosenRelatedLink.url,
+      title: chosenRelatedLink.title,
       frecency: RELATED_FRECENCY,
       lastVisitDate: chosenRelatedLink.lastVisitDate,
       type: "related",
+
+      // Choose the first site a user has visited as the target. In the future,
+      // this should be the site with the highest frecency. However, we currently
+      // store frecency by URL not by site.
+      targetedSite: targetedSites.get(chosenRelatedLink.url).length ?
+        targetedSites.get(chosenRelatedLink.url)[0] : null
     });
     return chosenRelatedLink;
    },
 
   /**
    * Return the object to its pre-init state
    */
   reset: function DirectoryLinksProvider_reset() {
--- a/browser/modules/ReaderParent.jsm
+++ b/browser/modules/ReaderParent.jsm
@@ -55,29 +55,29 @@ let ReaderParent = {
         });
         break;
 
       case "Reader:FaviconRequest": {
         // XXX: To implement.
         break;
       }
       case "Reader:ListStatusRequest":
-        ReadingList.containsURL(message.data.url).then(inList => {
+        ReadingList.hasItemForURL(message.data.url).then(inList => {
           let mm = message.target.messageManager
           // Make sure the target browser is still alive before trying to send data back.
           if (mm) {
             mm.sendAsyncMessage("Reader:ListStatusData",
                                 { inReadingList: inList, url: message.data.url });
           }
         });
         break;
 
       case "Reader:RemoveFromList":
         // We need to get the "real" item to delete it.
-        ReadingList.getItemForURL(message.data.url).then(item => {
+        ReadingList.itemForURL(message.data.url).then(item => {
           ReadingList.deleteItem(item)
         });
         break;
 
       case "Reader:Share":
         // XXX: To implement.
         break;
 
--- a/browser/themes/shared/newtab/newTab.inc.css
+++ b/browser/themes/shared/newtab/newTab.inc.css
@@ -117,35 +117,40 @@
   background-origin: padding-box;
   background-clip: padding-box;
   background-repeat: no-repeat;
   background-size: cover;
   border-radius: inherit;
   transition: opacity 100ms ease-out;
 }
 
-.newtab-site:hover .newtab-thumbnail.enhanced-content {
+.newtab-cell:not([ignorehover]) .newtab-site:hover .newtab-thumbnail.enhanced-content {
   opacity: 0;
 }
 
 .newtab-site[type=affiliate] .newtab-thumbnail,
 .newtab-site[type=enhanced] .newtab-thumbnail,
 .newtab-site[type=organic] .newtab-thumbnail,
 .newtab-site[type=sponsored] .newtab-thumbnail {
   background-position: center center;
   background-size: auto;
 }
 
 /* TITLES */
 #newtab-intro-what,
 .newtab-sponsored,
-.newtab-title {
+.newtab-title,
+.newtab-suggested  {
   color: #5c5c5c;
 }
 
+.newtab-suggested {
+  background-color: white;
+}
+
 .newtab-site:hover .newtab-title {
   color: #222;
 }
 
 .newtab-site[pinned] .newtab-title {
   padding: 0 15px;
 }
 
--- a/browser/themes/shared/readinglist/sidebar.inc.css
+++ b/browser/themes/shared/readinglist/sidebar.inc.css
@@ -46,17 +46,17 @@ body {
   min-width: 64px;
   max-width: 64px;
   min-height: 40px;
   max-height: 40px;
   border: 1px solid white;
   box-shadow: 0px 1px 2px rgba(0,0,0,.35);
   margin: 5px;
   background-color: #fff;
-  background-size: contain;
+  background-size: cover;
   background-repeat: no-repeat;
   background-position: center;
   background-image: url("chrome://branding/content/silhouette-40.svg");
 }
 
 .item-summary-container {
   display: flex;
   flex-flow: column;
--- a/gfx/thebes/gfxPlatform.h
+++ b/gfx/thebes/gfxPlatform.h
@@ -161,17 +161,18 @@ GetBackendName(mozilla::gfx::BackendType
 
 enum class DeviceResetReason
 {
   OK = 0,
   HUNG,
   REMOVED,
   RESET,
   DRIVER_ERROR,
-  INVALID_CALL
+  INVALID_CALL,
+  OUT_OF_MEMORY
 };
 
 class gfxPlatform {
     friend class SRGBOverrideObserver;
 
 public:
     typedef mozilla::gfx::Color Color;
     typedef mozilla::gfx::DataSourceSurface DataSourceSurface;
--- a/gfx/thebes/gfxWindowsPlatform.cpp
+++ b/gfx/thebes/gfxWindowsPlatform.cpp
@@ -1163,16 +1163,20 @@ gfxWindowsPlatform::DidRenderingDeviceRe
         case DXGI_ERROR_DEVICE_RESET:
           *aResetReason = DeviceResetReason::RESET;
           break;
         case DXGI_ERROR_DRIVER_INTERNAL_ERROR:
           *aResetReason = DeviceResetReason::DRIVER_ERROR;
           break;
         case DXGI_ERROR_INVALID_CALL:
           *aResetReason = DeviceResetReason::INVALID_CALL;
+          break;
+        case E_OUTOFMEMORY:
+          *aResetReason = DeviceResetReason::OUT_OF_MEMORY;
+          break;
         default:
           MOZ_ASSERT(false);
         }
       }
       return true;
     }
   }
   if (mD3D11ContentDevice) {
--- a/mobile/android/base/tests/testAboutPasswords.js
+++ b/mobile/android/base/tests/testAboutPasswords.js
@@ -58,44 +58,12 @@ add_test(function test_passwords_list() 
   let logins_list = browser.contentDocument.getElementById("logins-list");
 
   let hostname = logins_list.querySelector(".hostname");
   do_check_eq(hostname.textContent, LOGIN_FIELDS.hostname);
 
   let username = logins_list.querySelector(".username");
   do_check_eq(username.textContent, LOGIN_FIELDS.username);
 
-  let login_item = browser.contentDocument.querySelector("#logins-list > .login-item");
-  browser.addEventListener("PasswordsDetailsLoad", function() {
-    browser.removeEventListener("PasswordsDetailsLoad", this, false);
-    Services.tm.mainThread.dispatch(run_next_test, Ci.nsIThread.DISPATCH_NORMAL);
-  }, false);
-
-  // Expand item details.
-  login_item.click();
-});
-
-add_test(function test_passwords_details() {
-  let login_details = browser.contentDocument.getElementById("login-details");
-
-  let hostname = login_details.querySelector(".hostname");
-  do_check_eq(hostname.textContent, LOGIN_FIELDS.hostname);
-  let username = login_details.querySelector(".username");
-  do_check_eq(username.textContent, LOGIN_FIELDS.username);
-
-  // Check that details page opens link to host.
-  BrowserApp.deck.addEventListener("TabOpen", (tabevent) => {
-    // Wait for tab to finish loading.
-    let browser_target = tabevent.target;
-    browser_target.addEventListener("load", () => {
-      browser_target.removeEventListener("load", this, true);
-
-      do_check_eq(BrowserApp.selectedTab.browser.currentURI.spec, LOGIN_FIELDS.hostname);
-      Services.tm.mainThread.dispatch(run_next_test, Ci.nsIThread.DISPATCH_NORMAL);
-    }, true);
-
-    BrowserApp.deck.removeEventListener("TabOpen", this, false);
-  }, false);
-
-  browser.contentDocument.getElementById("details-header").click();
+  run_next_test();
 });
 
 run_next_test();
--- a/mobile/android/chrome/content/aboutPasswords.js
+++ b/mobile/android/chrome/content/aboutPasswords.js
@@ -12,16 +12,19 @@ XPCOMUtils.defineLazyGetter(window, "gCh
   window.QueryInterface(Ci.nsIInterfaceRequestor)
     .getInterface(Ci.nsIWebNavigation)
     .QueryInterface(Ci.nsIDocShellTreeItem)
     .rootTreeItem
     .QueryInterface(Ci.nsIInterfaceRequestor)
     .getInterface(Ci.nsIDOMWindow)
     .QueryInterface(Ci.nsIDOMChromeWindow));
 
+XPCOMUtils.defineLazyModuleGetter(this, "Prompt",
+                                  "resource://gre/modules/Prompt.jsm");
+
 let debug = Cu.import("resource://gre/modules/AndroidLog.jsm", {}).AndroidLog.d.bind(null, "AboutPasswords");
 
 let gStringBundle = Services.strings.createBundle("chrome://browser/locale/aboutPasswords.properties");
 
 function copyStringAndToast(string, notifyString) {
   try {
     let clipboard = Cc["@mozilla.org/widget/clipboardhelper;1"].getService(Ci.nsIClipboardHelper);
     clipboard.copyString(string);
@@ -120,19 +123,43 @@ let Passwords = {
     }
   },
 
   _createItemForLogin: function (login) {
     let loginItem = document.createElement("div");
 
     loginItem.setAttribute("loginID", login.guid);
     loginItem.className = "login-item list-item";
+
     loginItem.addEventListener("click", () => {
-      this._showDetails(loginItem);
-      history.pushState({ id: login.guid }, document.title);
+      let prompt = new Prompt({
+        window: window,
+      });
+      let menuItems = [
+        { label: gStringBundle.GetStringFromName("passwordsMenu.copyPassword") },
+        { label: gStringBundle.GetStringFromName("passwordsMenu.copyUsername") },
+        { label: gStringBundle.GetStringFromName("passwordsMenu.details") } ];
+
+      prompt.setSingleChoiceItems(menuItems);
+      prompt.show((data) => {
+        // Switch on indices of buttons, as they were added when creating login item.
+        switch (data.button) {
+          case 0:
+            copyStringAndToast(login.password, gStringBundle.GetStringFromName("passwordsDetails.passwordCopied"));
+            break;
+          case 1:
+            copyStringAndToast(login.username, gStringBundle.GetStringFromName("passwordsDetails.usernameCopied"));
+            break;
+          case 2:
+            this._showDetails(loginItem);
+            history.pushState({ id: login.guid }, document.title);
+            break;
+        }
+      });
+
     }, true);
 
     // Create item icon.
     let img = document.createElement("div");
     img.className = "icon";
 
     // Load favicon from cache.
     Messaging.sendRequestForResult({
--- a/mobile/android/locales/en-US/chrome/aboutPasswords.properties
+++ b/mobile/android/locales/en-US/chrome/aboutPasswords.properties
@@ -1,9 +1,13 @@
 # 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/.
 
+passwordsMenu.copyPassword=Copy password
+passwordsMenu.copyUsername=Copy username
+passwordsMenu.details=Details
+
 passwordsDetails.age=Age: %S days
 
 passwordsDetails.copyFailed=Copy failed
 passwordsDetails.passwordCopied=Password copied
 passwordsDetails.usernameCopied=Username copied
--- a/toolkit/components/telemetry/Histograms.json
+++ b/toolkit/components/telemetry/Histograms.json
@@ -209,17 +209,17 @@
     "high": "10000",
     "n_buckets": 50,
     "description": "Time spent on one asynchronous SnowWhite freeing (ms)"
   },
   "DEVICE_RESET_REASON": {
     "expires_in_version": "never",
     "kind": "enumerated",
     "n_values": 10,
-    "description": "GPU Device Reset Reason (ok, hung, removed, reset, internal error, invalid call)"
+    "description": "GPU Device Reset Reason (ok, hung, removed, reset, internal error, invalid call, out of memory)"
   },
   "FORGET_SKIPPABLE_MAX": {
     "expires_in_version": "never",
     "kind": "exponential",
     "high": "10000",
     "n_buckets": 50,
     "description": "Max time spent on one forget skippable (ms)"
   },
--- a/toolkit/modules/NewTabUtils.jsm
+++ b/toolkit/modules/NewTabUtils.jsm
@@ -930,17 +930,22 @@ let Links = {
 
   /**
    * Merges the cached lists of links from all providers whose lists are cached.
    * @return The merged list.
    */
   _getMergedProviderLinks: function Links__getMergedProviderLinks() {
     // Build a list containing a copy of each provider's sortedLinks list.
     let linkLists = [];
-    for (let links of this._providers.values()) {
+    for (let provider of this._providers.keys()) {
+      if (!AllPages.enhanced && provider != PlacesProvider) {
+        // Only show history tiles if we're not in 'enhanced' mode.
+        continue;
+      }
+      let links = this._providers.get(provider);
       if (links && links.sortedLinks) {
         linkLists.push(links.sortedLinks.slice());
       }
     }
 
     function getNextLink() {
       let minLinks = null;
       for (let links of linkLists) {
@@ -1243,21 +1248,29 @@ this.NewTabUtils = {
       ExpirationFilter.init();
       Telemetry.init();
       return true;
     }
     return false;
   },
 
   getProviderLinks: function(aProvider) {
-    return Links._providers.get(aProvider).sortedLinks;
+    let cache = Links._providers.get(aProvider);
+    if (cache && cache.sortedLinks) {
+      return cache.sortedLinks;
+    }
+    return [];
   },
 
   isTopSiteGivenProvider: function(aSite, aProvider) {
-    return Links._providers.get(aProvider).siteMap.has(aSite);
+    let cache = Links._providers.get(aProvider);
+    if (cache && cache.siteMap) {
+      return cache.siteMap.has(aSite);
+    }
+    return false;
   },
 
   isTopPlacesSite: function(aSite) {
     return this.isTopSiteGivenProvider(aSite, PlacesProvider);
   },
 
   /**
    * Restores all sites that have been removed from the grid.
--- a/toolkit/modules/tests/xpcshell/test_NewTabUtils.js
+++ b/toolkit/modules/tests/xpcshell/test_NewTabUtils.js
@@ -2,21 +2,48 @@
    http://creativecommons.org/publicdomain/zero/1.0/ */
 
 // See also browser/base/content/test/newtab/.
 
 const { classes: Cc, interfaces: Ci, results: Cr, utils: Cu } = Components;
 Cu.import("resource://gre/modules/NewTabUtils.jsm");
 Cu.import("resource://gre/modules/Promise.jsm");
 Cu.import("resource://gre/modules/Task.jsm");
+Cu.import("resource://gre/modules/Services.jsm");
+
+const PREF_NEWTAB_ENHANCED = "browser.newtabpage.enhanced";
 
 function run_test() {
+  Services.prefs.setBoolPref(PREF_NEWTAB_ENHANCED, true);
   run_next_test();
 }
 
+add_task(function validCacheMidPopulation() {
+  let expectedLinks = makeLinks(0, 3, 1);
+
+  let provider = new TestProvider(done => done(expectedLinks));
+  provider.maxNumLinks = expectedLinks.length;
+
+  NewTabUtils.initWithoutProviders();
+  NewTabUtils.links.addProvider(provider);
+  let promise = new Promise(resolve => NewTabUtils.links.populateCache(resolve));
+
+  // isTopSiteGivenProvider() and getProviderLinks() should still return results
+  // even when cache is empty or being populated.
+  do_check_false(NewTabUtils.isTopSiteGivenProvider("example1.com", provider));
+  do_check_links(NewTabUtils.getProviderLinks(provider), []);
+
+  yield promise;
+
+  // Once the cache is populated, we get the expected results
+  do_check_true(NewTabUtils.isTopSiteGivenProvider("example1.com", provider));
+  do_check_links(NewTabUtils.getProviderLinks(provider), expectedLinks);
+  NewTabUtils.links.removeProvider(provider);
+});
+
 add_task(function notifyLinkDelete() {
   let expectedLinks = makeLinks(0, 3, 1);
 
   let provider = new TestProvider(done => done(expectedLinks));
   provider.maxNumLinks = expectedLinks.length;
 
   NewTabUtils.initWithoutProviders();
   NewTabUtils.links.addProvider(provider);