Bug 808770 - Implement pin/unpin of Top Sites, new Site model and refresh method on richgriditem. r=mbrubeck
authorSam Foster <sfoster@mozilla.com>
Wed, 27 Mar 2013 15:34:52 +0000
changeset 126428 44d4d2234a6e
parent 126427 4967844a6ae2
child 126429 a3afa561d8af
push id25458
push usersfoster@mozilla.com
push dateWed, 27 Mar 2013 15:40:00 +0000
treeherdermozilla-inbound@44d4d2234a6e [default view] [failures only]
perfherder[talos] [build metrics] [platform microbench] (compared to previous push)
reviewersmbrubeck
bugs808770
milestone22.0a1
first release with
nightly linux32
nightly linux64
nightly mac
nightly win32
nightly win64
last release without
nightly linux32
nightly linux64
nightly mac
nightly win32
nightly win64
Bug 808770 - Implement pin/unpin of Top Sites, new Site model and refresh method on richgriditem. r=mbrubeck
browser/metro/base/content/Site.js
browser/metro/base/content/TopSites.js
browser/metro/base/content/bindings/grid.xml
browser/metro/base/content/browser-scripts.js
browser/metro/base/content/browser.xul
browser/metro/base/jar.mn
browser/metro/base/tests/Makefile.in
browser/metro/base/tests/browser_topsites.js
browser/metro/theme/browser.css
browser/metro/theme/platform.css
new file mode 100644
--- /dev/null
+++ b/browser/metro/base/content/Site.js
@@ -0,0 +1,72 @@
+// -*- Mode: js2; tab-width: 2; indent-tabs-mode: nil; js2-basic-offset: 2; js2-skip-preprocessor-directives: t; -*-
+/* 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/. */
+'use strict';
+
+/**
+ * dumb model class to provide default values for sites.
+ * link parameter/model object expected to have a .url property, and optionally .title
+ */
+function Site(aLink) {
+  if(!aLink.url) {
+    throw Cr.NS_ERROR_INVALID_ARG;
+  }
+  this._link = aLink;
+}
+
+Site.prototype = {
+  icon: '',
+  get url() {
+    return this._link.url;
+  },
+  get title() {
+    // use url if no title was recorded
+    return this._link.title || this._link.url;
+  },
+  get label() {
+    // alias for .title
+    return this.title;
+  },
+  get pinned() {
+    return NewTabUtils.pinnedLinks.isPinned(this);
+  },
+  get contextActions() {
+    return [
+      'delete', // delete means hide here
+      this.pinned ? 'unpin' : 'pin'
+    ];
+  },
+  blocked: false,
+  get attributeValues() {
+    return {
+      value: this.url,
+      label: this.title,
+      pinned: this.pinned ? true : undefined,
+      selected: this.selected,
+      customColor: this.color,
+      customImage: this.backgroundImage,
+      iconURI: this.icon,
+      "data-contextactions": this.contextActions.join(',')
+    };
+  },
+  applyToTileNode: function(aNode) {
+    // apply this site's properties as attributes on a tile element
+    // the decorated node acts as a view-model for the tile binding
+    let attrs = this.attributeValues;
+    for (let key in attrs) {
+      if (undefined === attrs[key]) {
+        aNode.removeAttribute(key);
+      } else {
+        aNode.setAttribute(key, attrs[key]);
+      }
+    }
+    // is binding already applied?
+    if (aNode.refresh) {
+      // just update it
+      aNode.refresh();
+    } else {
+      // these attribute values will get picked up later when the binding is applied
+    }
+  }
+};
--- a/browser/metro/base/content/TopSites.js
+++ b/browser/metro/base/content/TopSites.js
@@ -1,42 +1,122 @@
 // -*- Mode: js2; tab-width: 2; indent-tabs-mode: nil; js2-basic-offset: 2; js2-skip-preprocessor-directives: t; -*-
 /* 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/. */
 'use strict';
  let prefs = Components.classes["@mozilla.org/preferences-service;1"].
       getService(Components.interfaces.nsIPrefBranch);
-
 Cu.import("resource://gre/modules/PageThumbs.jsm");
 
-// singleton to provide data-level functionality to the views
+/**
+ * singleton to provide data-level functionality to the views
+ */
 let TopSites = {
-  pinSite: function(aId, aSlotIndex) {
-    Util.dumpLn("TopSites.pinSite: " + aId + ", (TODO)");
-    // FIXME: implementation needed
-    return true; // operation was successful
+  _initialized: false,
+
+  Site: Site,
+
+  prepareCache: function(aForce){
+    // front to the NewTabUtils' links cache
+    //  -ensure NewTabUtils.links links are pre-cached
+
+    // avoid re-fetching links data while a fetch is in flight
+    if (this._promisedCache && !aForce) {
+      return this._promisedCache;
+    }
+    let deferred = Promise.defer();
+    this._promisedCache = deferred.promise;
+
+    NewTabUtils.links.populateCache(function () {
+      deferred.resolve();
+      this._promisedCache = null;
+      this._sites = null;  // reset our sites cache so they are built anew
+      this._sitesDirty.clear();
+    }.bind(this), true);
+    return this._promisedCache;
+  },
+
+  _sites: null,
+  _sitesDirty: new Set(),
+  getSites: function() {
+    if (this._sites) {
+      return this._sites;
+    }
+
+    let links = NewTabUtils.links.getLinks();
+    let sites = links.map(function(aLink){
+      let site = new Site(aLink);
+      return site;
+    });
+
+    // reset state
+    this._sites = sites;
+    this._sitesDirty.clear();
+    return this._sites;
   },
-  unpinSite: function(aId) {
-    Util.dumpLn("TopSites.unpinSite: " + aId + ", (TODO)");
-    // FIXME: implementation needed
-    return true; // operation was successful
+
+  /**
+   * Get list of top site as in need of update/re-render
+   * @param aSite Optionally add Site arguments to be refreshed/updated
+   */
+  dirty: function() {
+    // add any arguments for more fine-grained updates rather than invalidating the whole collection
+    for (let i=0; i<arguments.length; i++) {
+      this._sitesDirty.add(arguments[i]);
+    }
+    return this._sitesDirty;
+  },
+
+  /**
+   * Cause update of top sites
+   */
+  update: function() {
+    NewTabUtils.allPages.update();
+    // then clear all the dirty flags
+    this._sitesDirty.clear();
   },
-  hideSite: function(aId) {
-    Util.dumpLn("TopSites.hideSite: " + aId + ", (TODO)");
-    // FIXME: implementation needed
-    return true; // operation was successful
+
+  pinSite: function(aSite, aSlotIndex) {
+    if (!(aSite && aSite.url)) {
+      throw Cr.NS_ERROR_INVALID_ARG
+    }
+    // pinned state is a pref, using Storage apis therefore sync
+    NewTabUtils.pinnedLinks.pin(aSite, aSlotIndex);
+    this.dirty(aSite);
+    this.update();
   },
-  restoreSite: function(aId) {
-    Util.dumpLn("TopSites.restoreSite: " + aId + ", (TODO)");
-    // FIXME: implementation needed
-    return true; // operation was successful
+  unpinSite: function(aSite) {
+    if (!(aSite && aSite.url)) {
+      throw Cr.NS_ERROR_INVALID_ARG
+    }
+    // pinned state is a pref, using Storage apis therefore sync
+    NewTabUtils.pinnedLinks.unpin(aSite);
+    this.dirty(aSite);
+    this.update();
+  },
+  hideSite: function(aSite) {
+    if (!(aSite && aSite.url)) {
+      throw Cr.NS_ERROR_INVALID_ARG
+    }
+    // FIXME: implementation needed, covered by bug 812291
+  },
+  restoreSite: function(aSite) {
+    if (!(aSite && aSite.url)) {
+      throw Cr.NS_ERROR_INVALID_ARG
+    }
+    // FIXME: implementation needed, covered by bug 812291
+  },
+  _linkFromNode: function _linkFromNode(aNode) {
+    return {
+      url: aNode.getAttribute("value"),
+      title: aNode.getAttribute("label")
+    };
   }
 };
-
 // The value of useThumbs should not be changed over the lifetime of
 //   the object.
 function TopSitesView(aGrid, aMaxSites, aUseThumbnails) {
   this._set = aGrid;
   this._set.controller = this;
   this._topSitesMax = aMaxSites;
   this._useThumbs = aUseThumbnails;
 
@@ -45,130 +125,145 @@ function TopSitesView(aGrid, aMaxSites, 
 
   let history = Cc["@mozilla.org/browser/nav-history-service;1"].
                 getService(Ci.nsINavHistoryService);
   history.addObserver(this, false);
   if (this._useThumbs) {
     PageThumbs.addExpirationFilter(this);
     Services.obs.addObserver(this, "Metro:RefreshTopsiteThumbnail", false);
   }
+
+  NewTabUtils.allPages.register(this);
+  TopSites.prepareCache().then(function(){
+    this.populateGrid();
+  }.bind(this));
 }
 
 TopSitesView.prototype = {
   _set:null,
   _topSitesMax: null,
+  // isUpdating used only for testing currently
+  isUpdating: false,
 
   handleItemClick: function tabview_handleItemClick(aItem) {
     let url = aItem.getAttribute("value");
     BrowserUI.goToURI(url);
   },
 
   doActionOnSelectedTiles: function(aActionName) {
     let tileGroup = this._set;
     let selectedTiles = tileGroup.selectedItems;
 
     switch (aActionName){
       case "delete":
         Array.forEach(selectedTiles, function(aNode) {
-          let id = aNode.getAttribute("data-itemid");
+          let site = TopSites._linkFromNode(aNode);
           // add some class to transition element before deletion?
-          if (TopSites.hideSite(id)) {
-            // success
+          TopSites.hideSite(site);
+          if (aNode.contextActions){
             aNode.contextActions.delete('delete');
             aNode.contextActions.add('restore');
           }
-          // TODO: we'll use some callback/event to remove the item or re-draw the grid
         });
         break;
       case "pin":
         Array.forEach(selectedTiles, function(aNode) {
-          let id = aNode.getAttribute("data-itemid");
-          if (TopSites.pinSite(id)) {
-            // success
+          let site = TopSites._linkFromNode(aNode);
+          let index = Array.indexOf(aNode.control.children, aNode);
+          TopSites.pinSite(site, index);
+          if (aNode.contextActions) {
             aNode.contextActions.delete('pin');
             aNode.contextActions.add('unpin');
           }
-          // TODO: we'll use some callback/event to add some class to
-          // indicate element is pinned?
         });
         break;
       case "unpin":
         Array.forEach(selectedTiles, function(aNode) {
-          let id = aNode.getAttribute("data-itemid");
-          if (TopSites.unpinSite(id)) {
-            // success
+          let site = TopSites._linkFromNode(aNode);
+          TopSites.unpinSite(site);
+          if (aNode.contextActions) {
             aNode.contextActions.delete('unpin');
             aNode.contextActions.add('pin');
           }
-          // TODO: we'll use some callback/event to add some class to
-          // indicate element is pinned (or just redraw grid)
         });
         break;
       // default: no action
     }
   },
 
   handleEvent: function(aEvent) {
     switch (aEvent.type){
       case "context-action":
         this.doActionOnSelectedTiles(aEvent.action);
         break;
     }
   },
 
-  populateGrid: function populateGrid() {
-    let query = gHistSvc.getNewQuery();
-    let options = gHistSvc.getNewQueryOptions();
-    options.excludeQueries = true;
-    options.queryType = options.QUERY_TYPE_HISTORY;
-    options.maxResults = this._topSitesMax;
-    options.resultType = options.RESULTS_AS_URI;
-    options.sortingMode = options.SORT_BY_FRECENCY_DESCENDING;
+  update: function() {
+    // called by the NewTabUtils.allPages.update, notifying us of data-change in topsites
+    let grid = this._set,
+        dirtySites = TopSites.dirty();
 
-    let result = gHistSvc.executeQuery(query, options);
-    let rootNode = result.root;
-    rootNode.containerOpen = true;
-    let childCount = rootNode.childCount;
+    if (dirtySites.size) {
+      // we can just do a partial update and refresh the node representing each dirty tile
+      for (let site of dirtySites) {
+        let tileNode = grid.querySelector("[value='"+site.url+"']");
+        if (tileNode) {
+          this.updateTile(tileNode, new Site(site));
+        }
+      }
+    } else {
+        // flush, recreate all
+      this.isUpdating = true;
+      // destroy and recreate all item nodes
+      let item;
+      while ((item = grid.firstChild)){
+        grid.removeChild(item);
+      }
+      this.populateGrid();
+    }
+  },
 
-    // use this property as the data-itemid attribute on the tiles
-    // which identifies the site
-    let identifier = 'uri';
+  updateTile: function(aTileNode, aSite, aArrangeGrid) {
+    if (this._useThumbs) {
+      aSite.backgroundImage = 'url("'+PageThumbs.getThumbnailURL(aSite.url)+'")';
+    } else {
+      delete aSite.backgroundImage;
+    }
+    aSite.applyToTileNode(aTileNode);
+    if (aArrangeGrid) {
+      this._set.arrangeItems();
+    }
+  },
 
-    function isPinned(aNode) {
-      // placeholder condition,
-      // FIXME: do the actual lookup/check
-      return (aNode.uri.indexOf('google') > -1);
+  populateGrid: function populateGrid() {
+    this.isUpdating = true;
+
+    let sites = TopSites.getSites();
+    let length = Math.min(sites.length, this._topSitesMax || Infinity);
+    let tileset = this._set;
+
+    // if we're updating with a collection that is smaller than previous
+    // remove any extra tiles
+    while (tileset.children.length > length) {
+      tileset.removeChild(tileset.children[tileset.children.length -1]);
     }
 
-    for (let i = 0; i < childCount; i++) {
-      let node = rootNode.getChild(i);
-      let uri = node.uri;
-      let title = node.title || uri;
+    for (let idx=0; idx < length; idx++) {
+      let isNew = !tileset.children[idx],
+          item = tileset.children[idx] || document.createElement("richgriditem"),
+          site = sites[idx];
 
-      let supportedActions = ['delete'];
-      // placeholder condition - check field/property for this site
-      if (isPinned(node)) {
-        supportedActions.push('unpin');
-      } else {
-        supportedActions.push('pin');
-      }
-      let item = this._set.appendItem(title, uri);
-      item.setAttribute("iconURI", node.icon);
-      item.setAttribute("data-itemid", node[identifier]);
-      // here is where we could add verbs based on pinned etc. state
-      item.setAttribute("data-contextactions", supportedActions.join(','));
-
-      if (this._useThumbs) {
-        let thumbnail = PageThumbs.getThumbnailURL(uri);
-        let cssthumbnail = 'url("'+thumbnail+'")';
-        // Use setAttribute because binding properties may not be available yet.
-        item.setAttribute("customImage", cssthumbnail);
+      this.updateTile(item, site);
+      if (isNew) {
+        tileset.appendChild(item);
       }
     }
-    rootNode.containerOpen = false;
+    tileset.arrangeItems();
+    this.isUpdating = false;
   },
 
   forceReloadOfThumbnail: function forceReloadOfThumbnail(url) {
       let nodes = this._set.querySelectorAll('richgriditem[value="'+url+'"]');
       for (let item of nodes) {
         item.refreshBackgroundImage();
       }
   },
@@ -238,19 +333,17 @@ let TopSitesStartView = {
   get _grid() { return document.getElementById("start-topsites-grid"); },
 
   init: function init() {
     this._view = new TopSitesView(this._grid, 8, true);
     if (this._view.isFirstRun()) {
       let topsitesVbox = document.getElementById("start-topsites");
       topsitesVbox.setAttribute("hidden", "true");
     }
-    this._view.populateGrid();
   },
-
   uninit: function uninit() {
     this._view.destruct();
   },
 
   show: function show() {
     this._grid.arrangeItems(3, 3);
   },
 };
@@ -263,15 +356,13 @@ let TopSitesSnappedView = {
   },
 
   init: function() {
     this._view = new TopSitesView(this._grid, 8);
     if (this._view.isFirstRun()) {
       let topsitesVbox = document.getElementById("snapped-topsites");
       topsitesVbox.setAttribute("hidden", "true");
     }
-    this._view.populateGrid();
   },
-
   uninit: function uninit() {
     this._view.destruct();
   },
 };
--- a/browser/metro/base/content/bindings/grid.xml
+++ b/browser/metro/base/content/bindings/grid.xml
@@ -191,21 +191,23 @@
             }
           ]]>
         </setter>
       </property>
 
       <method name="appendItem">
         <parameter name="aLabel"/>
         <parameter name="aValue"/>
+        <parameter name="aSkipArrange"/>
         <body>
           <![CDATA[
             let addition = this._createItemElement(aLabel, aValue);
             this.appendChild(addition);
-            this.arrangeItems();
+            if (!aSkipArrange)
+              this.arrangeItems();
             return addition;
           ]]>
         </body>
       </method>
 
       <method name="clearAll">
         <body>
           <![CDATA[
@@ -217,38 +219,41 @@
           ]]>
         </body>
       </method>
 
       <method name="insertItemAt">
         <parameter name="anIndex"/>
         <parameter name="aLabel"/>
         <parameter name="aValue"/>
+        <parameter name="aSkipArrange"/>
         <body>
           <![CDATA[
             let existing = this.getItemAtIndex(anIndex);
             let addition = this._createItemElement(aLabel, aValue);
             if (existing) {
               this.insertBefore(addition, existing);
             } else {
               this.appendChild(addition);
             }
-            this.arrangeItems();
+            if (!aSkipArrange)
+              this.arrangeItems();
             return addition;
           ]]>
         </body>
       </method>
-
       <method name="removeItemAt">
         <parameter name="anIndex"/>
+        <parameter name="aSkipArrange"/>
         <body>
           <![CDATA[
             let removal = this.getItemAtIndex(anIndex);
             this.removeChild(removal);
-            this.arrangeItems();
+            if (!aSkipArrange)
+              this.arrangeItems();
             return removal;
           ]]>
         </body>
       </method>
 
       <method name="getIndexOfItem">
         <parameter name="anItem"/>
         <body>
@@ -445,21 +450,16 @@
         <parameter name="aValue"/>
         <body>
           <![CDATA[
             let item = this.ownerDocument.createElement("richgriditem");
             item.control = this;
             item.setAttribute("label", aLabel);
             if (aValue)
               item.setAttribute("value", aValue);
-
-            // copy over the richgrid's data-contextactions as each child is created
-            if (this.hasAttribute("data-contextactions")) {
-              item.setAttribute("data-contextactions", this.getAttribute("data-contextactions"));
-            }
             return item;
           ]]>
         </body>
       </method>
 
       <method name="_fireOnSelect">
         <body>
           <![CDATA[
@@ -491,34 +491,66 @@
 
     </implementation>
   </binding>
 
   <binding id="richgrid-item">
     <content>
       <xul:vbox anonid="anon-richgrid-item" class="richgrid-item-content" xbl:inherits="customImage">
         <xul:hbox class="richgrid-icon-container" xbl:inherits="customImage">
-          <xul:box class="richgrid-icon-box"><xul:image xbl:inherits="src=iconURI"/></xul:box>
+          <xul:box class="richgrid-icon-box"><xul:image anonid="anon-richgrid-item-icon" xbl:inherits="src=iconURI"/></xul:box>
           <xul:box flex="1" />
         </xul:hbox>
-        <xul:description class="richgrid-item-desc" xbl:inherits="value=label" crop="end"/>
+        <xul:description anonid="anon-richgrid-item-label" class="richgrid-item-desc" xbl:inherits="value=label" crop="end"/>
       </xul:vbox>
     </content>
 
     <implementation>
       <property name="_box" onget="return document.getAnonymousElementByAttribute(this, 'anonid', 'anon-richgrid-item');"/>
+      <property name="_icon" onget="return document.getAnonymousElementByAttribute(this, 'anonid', 'anon-richgrid-item-icon');"/>
+      <property name="_label" onget="return document.getAnonymousElementByAttribute(this, 'anonid', 'anon-richgrid-item-label');"/>
+      <property name="iconSrc"
+                onset="this._icon.src = val; this.setAttribute('iconURI', val);"
+                onget="return this._icon.src;" />
 
       <property name="selected" onget="return this.hasAttribute('selected');" />
+      <property name="url"
+                onget="return this.getAttribute('value')"
+                onset="this.setAttribute('value', val);"/>
+      <property name="label"
+                onget="return this._label.getAttribute('value')"
+                onset="this.setAttribute('label', val); this._label.setAttribute('value', val);"/>
+      <property name="pinned"
+                onget="return this.hasAttribute('pinned')"
+                onset="if (val) { this.setAttribute('pinned', val) } else this.removeAttribute('pinned');"/>
 
       <constructor>
         <![CDATA[
-          this.color = this.getAttribute("customColor");
-          this.backgroundImage = this.getAttribute("customImage");
+            this.refresh();
         ]]>
       </constructor>
+      <method name="refresh">
+        <body>
+          <![CDATA[
+            // Seed the binding properties from bound-node attribute values
+            // Usage: node.refresh()
+            //        - reinitializes all binding properties from their associated attributes
+
+            this.iconSrc = this.getAttribute('iconURI');
+            this.color = this.getAttribute("customColor");
+            this.label = this.getAttribute('label');
+            // url getter just looks directly at attribute
+            // selected getter just looks directly at attribute
+            // pinned getter just looks directly at attribute
+            // value getter just looks directly at attribute
+            this.refreshBackgroundImage();
+            this._contextActions = null;
+          ]]>
+        </body>
+      </method>
 
       <property name="control">
         <getter><![CDATA[
           var parent = this.parentNode;
           while (parent) {
             if (parent instanceof Components.interfaces.nsIDOMXULSelectControlElement)
               return parent;
             parent = parent.parentNode;
@@ -572,18 +604,29 @@
                 actions.split(/[,\s]+/).forEach(function(verb){
                   actionSet.add(verb);
                 });
               }
             }
             return this._contextActions;
           ]]>
         </getter>
+        <setter>
+          <![CDATA[
+            let actionSet = this._contextActions = new Set();
+            let actions = val;
+            if (actions) {
+              actions.split(/[,\s]+/).forEach(function(verb){
+                actionSet.add(verb);
+              });
+            }
+            return this._contextActions;
+          ]]>
+        </setter>
       </property>
-
     </implementation>
 
     <handlers>
       <handler event="click" button="0">
         <![CDATA[
           // left-click/touch handler
           this.control.handleItemClick(this, event);
         ]]>
--- a/browser/metro/base/content/browser-scripts.js
+++ b/browser/metro/base/content/browser-scripts.js
@@ -19,16 +19,24 @@ XPCOMUtils.defineLazyModuleGetter(this, 
 XPCOMUtils.defineLazyModuleGetter(this, "NetUtil",
                                   "resource://gre/modules/NetUtil.jsm");
 
 XPCOMUtils.defineLazyModuleGetter(this, "PdfJs",
                                   "resource://pdf.js/PdfJs.jsm");
 
 XPCOMUtils.defineLazyModuleGetter(this, "DownloadUtils",
                                   "resource://gre/modules/DownloadUtils.jsm");
+XPCOMUtils.defineLazyModuleGetter(this, "Promise",
+                                  "resource://gre/modules/commonjs/sdk/core/promise.js");
+
+XPCOMUtils.defineLazyModuleGetter(this, "Task",
+                                  "resource://gre/modules/Task.jsm");
+
+XPCOMUtils.defineLazyModuleGetter(this, "NewTabUtils",
+                                  "resource://gre/modules/NewTabUtils.jsm");
 
 XPCOMUtils.defineLazyModuleGetter(this, "Promise",
                                   "resource://gre/modules/commonjs/sdk/core/promise.js");
 
 XPCOMUtils.defineLazyModuleGetter(this, "Task",
                                   "resource://gre/modules/Task.jsm");
 
 /*
@@ -115,16 +123,18 @@ let ScriptContexts = {};
   ["DownloadsPanelView", "chrome://browser/content/downloads.js"],
   ["DownloadsView", "chrome://browser/content/downloads.js"],
   ["Downloads", "chrome://browser/content/downloads.js"],
   ["PreferencesPanelView", "chrome://browser/content/preferences.js"],
   ["BookmarksStartView", "chrome://browser/content/bookmarks.js"],
   ["HistoryView", "chrome://browser/content/history.js"],
   ["HistoryStartView", "chrome://browser/content/history.js"],
   ["HistoryPanelView", "chrome://browser/content/history.js"],
+  ["Site", "chrome://browser/content/Site.js"],
+  ["TopSites", "chrome://browser/content/TopSites.js"],
   ["TopSitesView", "chrome://browser/content/TopSites.js"],
   ["TopSitesSnappedView", "chrome://browser/content/TopSites.js"],
   ["TopSitesStartView", "chrome://browser/content/TopSites.js"],
   ["Sanitizer", "chrome://browser/content/sanitize.js"],
   ["SSLExceptions", "chrome://browser/content/exceptions.js"],
 #ifdef MOZ_SERVICES_SYNC
   ["WeaveGlue", "chrome://browser/content/sync.js"],
   ["SyncPairDevice", "chrome://browser/content/sync.js"],
--- a/browser/metro/base/content/browser.xul
+++ b/browser/metro/base/content/browser.xul
@@ -253,33 +253,31 @@
 
         <!-- Start UI -->
         <hbox id="start-container" flex="1" observes="bcast_windowState" class="meta content-height content-width" onclick="false;">
           <!-- portrait/landscape/filled view -->
           <hbox id="start" class="start-page" flex="1" observes="bcast_windowState">
             <scrollbox id="start-scrollbox" orient="horizontal" flex="1">
             <vbox id="start-topsites" class="meta-section">
               <label class="meta-section-title" value="&startTopSitesHeader.label;"/>
-              <richgrid id="start-topsites-grid" seltype="single" flex="1"/>
+              <richgrid id="start-topsites-grid" seltype="multiple" flex="1"/>
             </vbox>
-
             <vbox id="start-bookmarks" class="meta-section">
               <label class="meta-section-title" value="&startBookmarksHeader.label;"
                 onclick="PanelUI.show('bookmarks-container');"/>
-              <richgrid id="start-bookmarks-grid" seltype="single" flex="1"/>
+              <richgrid id="start-bookmarks-grid" seltype="multiple" flex="1"/>
             </vbox>
-
             <vbox id="start-history" class="meta-section">
               <label class="meta-section-title" value="&startHistoryHeader.label;"/>
-              <richgrid id="start-history-grid" seltype="single" flex="1"/>
+              <richgrid id="start-history-grid" seltype="multiple" flex="1"/>
             </vbox>
             <vbox id="start-remotetabs" class="meta-section">
               <label class="meta-section-title" value="&startRemoteTabsHeader.label;"
                 onclick="PanelUI.show('remotetabs-container');"/>
-              <richgrid id="start-remotetabs-grid" seltype="single" flex="1"/>
+              <richgrid id="start-remotetabs-grid" seltype="multiple" flex="1"/>
             </vbox>
             </scrollbox>
           </hbox>
           <!-- snapped view -->
           <vbox id="snapped-start" class="start-page" observes="bcast_windowState">
             <scrollbox id="snapped-scrollbox" orient="vertical" flex="1">
               <vbox id="snapped-topsites">
                 <label class="meta-section-title" value="&startTopSitesHeader.label;"/>
@@ -308,17 +306,17 @@
 
     <!-- popup for content navigator helper -->
     <vbox id="content-navigator" top="0">
       <textbox id="find-helper-textbox" class="search-bar content-navigator-item" oncommand="FindHelperUI.search(this.value)" oninput="FindHelperUI.updateCommands(this.value);" type="search"/>
     </vbox>
 
     <!-- Windows 8 Appbar -->
     <appbar id="appbar" mousethrough="never" observes="bcast_windowState">
-      <!-- contextual actions temporarily hidden, pending #800996, #831918 -->
+      <!-- contextual actions temporarily hidden, pending #800996 -->
       <hbox id="contextualactions-tray" flex="1" hidden="true">
         <toolbarbutton id="delete-selected-button" hidden="true" oncommand="Appbar.dispatchContextualAction('delete')"/>
         <toolbarbutton id="restore-selected-button" hidden="true" oncommand="Appbar.dispatchContextualAction('restore')"/>
         <toolbarbutton id="pin-selected-button" hidden="true" oncommand="Appbar.dispatchContextualAction('pin')"/>
         <toolbarbutton id="unpin-selected-button" hidden="true" oncommand="Appbar.dispatchContextualAction('unpin')"/>
       </hbox>
       <hbox flex="1">
         <toolbarbutton id="download-button" oncommand="Appbar.onDownloadButton()"/>
@@ -530,22 +528,22 @@
             </hbox>
           </vbox>
         </vbox>
       </dialog>
     </box>
 #endif
 
     <box onclick="event.stopPropagation();" id="context-container" class="menu-container" hidden="true">
-      <!-- onclick is dom bug 835175 --> 
+      <!-- onclick is dom bug 835175 -->
       <vbox id="context-popup" class="menu-popup">
         <richlistbox id="context-commands" bindingType="contextmenu" flex="1">
           <!-- priority="low" items are hidden by default when a context is being displayed
                for two or more media types. (e.g. a linked image) -->
-          <!-- content types preceeded by '!' act as exclusion rules, the menu item will not 
+          <!-- content types preceeded by '!' act as exclusion rules, the menu item will not
                be displayed if the content type is present. -->
           <!-- Note the order of richlistitem here is important as it is reflected in the
                menu itself. -->
           <!-- ux spec: https://bug782810.bugzilla.mozilla.org/attachment.cgi?id=714804 -->
 
           <!-- Text related -->
           <!-- for text inputs, this will cut selected text -->
           <richlistitem id="context-cut" type="cut" onclick="ContextCommands.cut();">
--- a/browser/metro/base/jar.mn
+++ b/browser/metro/base/jar.mn
@@ -77,16 +77,17 @@ chrome.jar:
   content/sanitize.js                          (content/sanitize.js)
   content/input.js                             (content/input.js)
   content/Util.js                              (content/Util.js)
   content/bookmarks.js                         (content/bookmarks.js)
   content/preferences.js                       (content/preferences.js)
   content/exceptions.js                        (content/exceptions.js)
   content/downloads.js                         (content/downloads.js)
   content/history.js                           (content/history.js)
+  content/Site.js                              (content/Site.js)
   content/TopSites.js                          (content/TopSites.js)
   content/console.js                           (content/console.js)
   content/AnimatedZoom.js                      (content/AnimatedZoom.js)
   content/LoginManagerChild.js                 (content/LoginManagerChild.js)
   content/video.js                             (content/video.js)
 #ifdef MOZ_SERVICES_SYNC
 * content/sync.js                              (content/sync.js)
   content/RemoteTabs.js                        (content/RemoteTabs.js)
--- a/browser/metro/base/tests/Makefile.in
+++ b/browser/metro/base/tests/Makefile.in
@@ -24,18 +24,18 @@ BROWSER_TESTS = \
   browser_plugin_input.html \
   browser_plugin_input_mouse.js \
   browser_plugin_input_keyboard.js \
   browser_context_menu_tests.js \
   browser_context_menu_tests_01.html \
   browser_context_menu_tests_02.html \
   browser_context_menu_tests_03.html \
   text-block.html \
+  browser_topsites.js \
   $(NULL)
-
 BROWSER_TEST_RESOURCES = \
   res/image01.png \
   $(NULL)
 
 XPCSHELL_TESTS = unit
 
 # For now we're copying the actual Util code.
 # We should make this into a jsm module. See bug 848137
new file mode 100644
--- /dev/null
+++ b/browser/metro/base/tests/browser_topsites.js
@@ -0,0 +1,333 @@
+/* -*- Mode: C++; tab-width: 8; indent-tabs-mode: nil; c-basic-offset: 2 -*- */
+/* vim: set ts=2 et sw=2 tw=80: */
+/* Any copyright is dedicated to the Public Domain.
+   http://creativecommons.org/publicdomain/zero/1.0/ */
+
+"use strict";
+
+//////////////////////////////////////////////////////////////////////////
+// Test helpers
+
+function mockLinks(aLinks) {
+  // create link objects where index. corresponds to grid position
+  // falsy values are set to null
+  let links = (typeof aLinks == "string") ?
+              aLinks.split(/\s*,\s*/) : aLinks;
+
+  links = links.map(function (id) {
+    return (id) ? {url: "http://example.com/#" + id, title: id} : null;
+  });
+  return links;
+}
+
+function clearHistory() {
+  PlacesUtils.history.removeAllPages();
+}
+
+function fillHistory(aLinks) {
+  return Task.spawn(function(){
+    let numLinks = aLinks.length;
+    let transitionLink = Ci.nsINavHistoryService.TRANSITION_LINK;
+
+    let updateDeferred = Promise.defer();
+
+    for (let link of aLinks.reverse()) {
+      info("fillHistory with link: " + JSON.stringify(link));
+      let place = {
+        uri: Util.makeURI(link.url),
+        title: link.title,
+        visits: [{visitDate: Date.now() * 1000, transitionType: transitionLink}]
+      };
+      try {
+        PlacesUtils.asyncHistory.updatePlaces(place, {
+          handleError: function (aError) {
+            ok(false, "couldn't add visit to history");
+            throw new Task.Result(aError);
+          },
+          handleResult: function () {},
+          handleCompletion: function () {
+            if(--numLinks <= 0) {
+              updateDeferred.resolve(true);
+            }
+          }
+        });
+      } catch(e) {
+        ok(false, "because: " + e);
+      }
+    }
+    return updateDeferred.promise;
+  });
+}
+
+/**
+ * Allows to specify the list of pinned links (that have a fixed position in
+ * the grid.
+ * @param aLinksPattern the pattern (see below)
+ *
+ * Example: setPinnedLinks("foo,,bar")
+ * Result: 'http://example.com/#foo' is pinned in the first cell. 'http://example.com/#bar' is
+ *         pinned in the third cell.
+ */
+function setPinnedLinks(aLinks) {
+  let links = mockLinks(aLinks);
+
+  // (we trust that NewTabUtils works, and test our consumption of it)
+  // clear all existing pins
+  Array.forEach(NewTabUtils.pinnedLinks.links, function(aLink){
+    if(aLink)
+      NewTabUtils.pinnedLinks.unpin(aLink);
+  });
+
+  links.forEach(function(aLink, aIndex){
+    if(aLink) {
+      NewTabUtils.pinnedLinks.pin(aLink, aIndex);
+    }
+  });
+  NewTabUtils.pinnedLinks.save();
+}
+
+/**
+ * Allows to provide a list of links that is used to construct the grid.
+ * @param aLinksPattern the pattern (see below)
+ * @param aPinnedLinksPattern the pattern (see below)
+ *
+ * Example: setLinks("dougal,florence,zebedee")
+ * Result: [{url: "http://example.com/#dougal", title: "dougal"},
+ *          {url: "http://example.com/#florence", title: "florence"}
+ *          {url: "http://example.com/#zebedee", title: "zebedee"}]
+ * Example: setLinks("dougal,florence,zebedee","dougal,,zebedee")
+ * Result: http://example.com/#dougal is pinned at index 0, http://example.com/#florence at index 2
+ */
+
+function setLinks(aLinks, aPinnedLinks) {
+  let links = mockLinks(aLinks);
+  if (links.filter(function(aLink){
+    return !aLink;
+  }).length) {
+    throw new Error("null link objects in setLinks");
+  }
+
+  return Task.spawn(function() {
+    clearHistory();
+
+    yield Task.spawn(fillHistory(links));
+
+    if(aPinnedLinks) {
+      setPinnedLinks(aPinnedLinks);
+    }
+
+    // reset the TopSites state, have it update its cache with the new data fillHistory put there
+    yield TopSites.prepareCache(true);
+  });
+}
+
+function updatePagesAndWait() {
+  let deferredUpdate = Promise.defer();
+  let updater = {
+    update: function() {
+      NewTabUtils.allPages.unregister(updater);
+      deferredUpdate.resolve(true);
+    }
+  };
+  NewTabUtils.allPages.register(updater);
+  setTimeout(function() {
+    NewTabUtils.allPages.update();
+  }, 0);
+  return deferredUpdate.promise;
+}
+
+//////////////////////////////////////////////////////////////////////////
+
+function test() {
+  runTests();
+}
+
+gTests.push({
+  desc: "TopSites dependencies",
+  run: function() {
+    ok(NewTabUtils, "NewTabUtils is truthy");
+    ok(TopSites, "TopSites is truthy");
+  }
+});
+
+gTests.push({
+  desc: "load and display top sites",
+  setUp: function() {
+    // setup - set history to known state
+    yield setLinks("brian,dougal,dylan,ermintrude,florence,moose,sgtsam,train,zebedee,zeebad");
+    let grid = document.getElementById("start-topsites-grid");
+
+    yield updatePagesAndWait();
+    // pause until the update has fired and the view is finishd updating
+    yield waitForCondition(function(){
+      return !grid.controller.isUpdating;
+    });
+  },
+  run: function() {
+    let grid = document.getElementById("start-topsites-grid");
+    let items = grid.children;
+    is(items.length, 8, "should be 8 topsites"); // i.e. not 10
+    if(items.length) {
+      let firstitem = items[0];
+      is(
+        firstitem.getAttribute("label"),
+        "brian",
+        "first item label should be 'brian': " + firstitem.getAttribute("label")
+      );
+      is(
+        firstitem.getAttribute("value"),
+        "http://example.com/#brian",
+        "first item url should be 'http://example.com/#brian': " + firstitem.getAttribute("url")
+      );
+    }
+  }
+});
+
+gTests.push({
+  desc: "pinned sites",
+  pins: "dangermouse,zebedee,,,dougal",
+  setUp: function() {
+    // setup - set history to known state
+    yield setLinks(
+      "brian,dougal,dylan,ermintrude,florence,moose,sgtsam,train,zebedee,zeebad",
+      this.pins
+    );
+    yield updatePagesAndWait();
+    // pause until the update has fired and the view is finished updating
+    yield waitForCondition(function(){
+      let grid = document.getElementById("start-topsites-grid");
+      return !grid.controller.isUpdating;
+    });
+  },
+  run: function() {
+    // test that pinned state of each site as rendered matches our expectations
+    let pins = this.pins.split(",");
+    let items = document.getElementById("start-topsites-grid").children;
+    is(items.length, 8, "should be 8 topsites in the grid");
+
+    is(document.querySelectorAll("#start-topsites-grid > [pinned]").length, 3, "should be 3 children with 'pinned' attribute");
+    try {
+      Array.forEach(items, function(aItem, aIndex){
+        // pinned state should agree with the pins array
+        is(
+            aItem.hasAttribute("pinned"), !!pins[aIndex],
+            "site at index " + aIndex + " was " +aItem.hasAttribute("pinned")
+            +", should agree with " + !!pins[aIndex]
+        );
+        if (pins[aIndex]) {
+          is(
+            aItem.getAttribute("label"),
+            pins[aIndex],
+            "pinned site has correct label: " + pins[aIndex] +"=="+ aItem.getAttribute("label")
+          );
+        }
+      }, this);
+    } catch(e) {
+      ok(false, this.desc + ": Test of pinned state on items failed");
+      info("because: " + e.message + "\n" + e.stack);
+    }
+  }
+});
+
+gTests.push({
+  desc: "pin site",
+  setUp: function() {
+    // setup - set history to known state
+    yield setLinks("sgtsam,train,zebedee,zeebad", []); // nothing initially pinned
+    yield updatePagesAndWait();
+    // pause until the update has fired and the view is finished updating
+    yield waitForCondition(function(){
+      let grid = document.getElementById("start-topsites-grid");
+      return !grid.controller.isUpdating;
+    });
+  },
+  run: function() {
+    // pin a site
+    // test that site is pinned as expected
+    // and that sites fill positions around it
+    let grid = document.getElementById("start-topsites-grid"),
+        items = grid.children;
+    is(items.length, 4, this.desc + ": should be 4 topsites");
+
+    let tile = grid.children[2],
+        url = tile.getAttribute("value"),
+        title = tile.getAttribute("label");
+
+    info(this.desc + ": pinning site at index 2");
+    TopSites.pinSite({
+      url: url,
+      title: title
+    }, 2);
+
+    yield waitForCondition(function(){
+      return !grid.controller.isUpdating;
+    });
+
+    let thirdTile = grid.children[2];
+    ok( thirdTile.hasAttribute("pinned"), thirdTile.getAttribute("value")+ " should look pinned" );
+
+    // visit some more sites
+    yield fillHistory( mockLinks("brian,dougal,dylan,ermintrude,florence,moose") );
+
+    // force flush and repopulation of links cache
+    yield TopSites.prepareCache(true);
+    yield updatePagesAndWait();
+
+    // pause until the update has fired and the view is finishd updating
+    yield waitForCondition(function(){
+      return !grid.controller.isUpdating;
+    });
+
+    // check zebedee is still pinned at index 2
+    is( items[2].getAttribute("label"), "zebedee", "Pinned site remained at its index" );
+    ok( items[2].hasAttribute("pinned"), "3rd site should still look pinned" );
+  }
+});
+
+gTests.push({
+  desc: "unpin site",
+  pins: ",zebedee",
+  setUp: function() {
+    try {
+      // setup - set history to known state
+      yield setLinks(
+        "brian,dougal,dylan,ermintrude,florence,moose,sgtsam,train,zebedee,zeebad",
+        this.pins
+      );
+      yield updatePagesAndWait();
+
+      // pause until the update has fired and the view is finished updating
+      yield waitForCondition(function(){
+        let grid = document.getElementById("start-topsites-grid");
+        return !grid.controller.isUpdating;
+      });
+    } catch(e) {
+      info("caught error in setUp: " + e);
+      info("trace: " + e.stack);
+    }
+  },
+  run: function() {
+    // unpin a pinned site
+    // test that sites are unpinned as expected
+    let grid = document.getElementById("start-topsites-grid"),
+        items = grid.children;
+    is(items.length, 8, this.desc + ": should be 8 topsites");
+    let site = {
+      url: items[1].getAttribute("value"),
+      title: items[1].getAttribute("label")
+    };
+    // verify assumptions before unpinning this site
+    ok( NewTabUtils.pinnedLinks.isPinned(site), "2nd item is pinned" );
+    ok( items[1].hasAttribute("pinned"), "2nd item has pinned attribute" );
+
+    TopSites.unpinSite(site);
+
+    yield waitForCondition(function(){
+      return !grid.controller.isUpdating;
+    });
+
+    let secondTile = grid.children[1];
+    ok( !secondTile.hasAttribute("pinned"), "2nd item should no longer be marked as pinned" );
+    ok( !NewTabUtils.pinnedLinks.isPinned(site), "2nd item should no longer be pinned" );
+  }
+});
--- a/browser/metro/theme/browser.css
+++ b/browser/metro/theme/browser.css
@@ -619,19 +619,48 @@ appbar toolbarbutton[disabled] {
 }
 #star-button:hover {
   -moz-image-region: rect(40px, 360px, 80px, 320px) !important;
 }
 #star-button:active,
 #star-button[checked] {
   -moz-image-region: rect(80px, 360px, 120px, 320px) !important;
 }
+/* Tile-selection-Specific */
+#pin-selected-button {
+  -moz-image-region: rect(0px, 240px, 40px, 200px) !important;
+}
+#pin-selected-button:hover {
+  -moz-image-region: rect(40px, 240px, 80px, 200px) !important;
+}
+#pin-selected-button:active {
+  -moz-image-region: rect(80px, 240px, 120px, 200px) !important;
+}
+
+#unpin-selected-button {
+  -moz-image-region: rect(80px, 240px, 120px, 200px) !important;
+}
+#unpin-selected-button:hover {
+  -moz-image-region: rect(40px, 240px, 80px, 200px) !important;
+}
+#unpin-selected-button:active {
+  -moz-image-region: rect(0px, 240px, 40px, 200px) !important;
+}
+
+#delete-selected-button {
+  -moz-image-region: rect(0px, 480px, 40px, 440px) !important;
+}
+#delete-selected-button:hover {
+  -moz-image-region: rect(40px, 480px, 80px, 440px) !important;
+}
+#delete-selected-button:active {
+  -moz-image-region: rect(80px, 480px, 120px, 440px) !important;
+}
 
 /* Flyouts ---------------------------------------------------------------- */
-
 /* don't add a margin to the very top settings entry in flyouts */
 flyoutpanel > settings:first-child {
   margin-top: 0px;
 }
 
 /* Sync flyout pane */
 
 #sync-flyoutpanel {
--- a/browser/metro/theme/platform.css
+++ b/browser/metro/theme/platform.css
@@ -458,18 +458,18 @@ richgriditem {
   padding: @metro_spacing_small@;
 }
 
 richgriditem .richgrid-item-content {
   border: @metro_border_thin@ solid @tile_border_color@;
   box-shadow: 0 0 @metro_spacing_snormal@ rgba(0, 0, 0, 0.1);
   -moz-box-sizing: border-box;
   padding: 10px 8px 6px 8px;
+  position: relative;
 }
-
 .richgrid-item-content {
   background: #fff;
 }
 
 richgriditem[selected] .richgrid-item-content {
   border: @metro_border_xthick@ solid @selected_color@;
   padding: @metro_spacing_xxsmall@;
 }
@@ -478,18 +478,28 @@ richgriditem .richgrid-icon-container {
   padding-bottom: 2px;
 }
 
 richgriditem .richgrid-icon-box {
   padding: 4px;
   background: #fff;
   opacity: 1.0;
 }
-
-
+/* <sfoster> placeholder pinned-state indication, tracked as 854960 */
+richgriditem[pinned] .richgrid-item-content:after {
+    content: "\2193";
+    text-align: center;
+    position: absolute;
+    width: 16px;
+    right: 0;
+    top: 0;
+    outline: 1px solid rgb(255,153,0);
+    background-color: rgba(255,153,0,0.6);
+    color: rgb(153,51,0);
+}
 richgriditem[customColor] {
   color: #f1f1f1;
 }
 richgriditem[customImage] {
   color: #1a1a1a;
 }