Bug 1123517 - [ReadingList] Implement basic sidebar that lists unread ReadingList items. r=florian
☠☠ backed out by 6f36188ad818 ☠ ☠
authorBlair McBride <bmcbride@mozilla.com>
Thu, 19 Feb 2015 20:35:10 +1300
changeset 258680 9b07a29dcbdd
parent 258679 5dfb417f346e
child 258681 6f36188ad818
push id721
push userjlund@mozilla.com
push date2015-04-21 23:03 +0000
treeherdermozilla-release@d27c9211ebb3 [default view] [failures only]
perfherder[talos] [build metrics] [platform microbench] (compared to previous push)
reviewersflorian
bugs1123517
milestone38.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 1123517 - [ReadingList] Implement basic sidebar that lists unread ReadingList items. r=florian
browser/app/profile/firefox.js
browser/base/content/browser-menubar.inc
browser/base/content/browser-readinglist.js
browser/base/content/browser-sets.inc
browser/base/content/browser.js
browser/base/content/browser.xul
browser/base/content/test/BrowserUITestUtils.jsm
browser/base/moz.build
browser/components/customizableui/test/browser_988072_sidebar_events.js
browser/components/moz.build
browser/components/readinglist/ReadingList.jsm
browser/components/readinglist/jar.mn
browser/components/readinglist/moz.build
browser/components/readinglist/sidebar.js
browser/components/readinglist/sidebar.xhtml
browser/components/readinglist/test/ReadingListTestUtils.jsm
browser/components/readinglist/test/browser/browser.ini
browser/components/readinglist/test/browser/browser_sidebar_list.js
browser/components/readinglist/test/browser/browser_sidebar_mouse_nav.js
browser/components/readinglist/test/browser/browser_ui_enable_disable.js
browser/components/readinglist/test/browser/head.js
browser/locales/en-US/chrome/browser/browser.dtd
browser/themes/linux/jar.mn
browser/themes/osx/jar.mn
browser/themes/shared/readinglist/sidebar.css
browser/themes/windows/jar.mn
--- a/browser/app/profile/firefox.js
+++ b/browser/app/profile/firefox.js
@@ -1851,8 +1851,11 @@ pref("dom.ipc.processHangMonitor", true)
 // debugger is attached.
 pref("dom.ipc.reportProcessHangs", false);
 #else
 pref("dom.ipc.reportProcessHangs", true);
 #endif
 
 // Disable reader mode by default.
 pref("reader.parse-on-load.enabled", false);
+
+// Disable ReadingList by default.
+pref("browser.readinglist.enabled", false);
--- a/browser/base/content/browser-menubar.inc
+++ b/browser/base/content/browser-menubar.inc
@@ -218,16 +218,21 @@
                   <menupopup id="viewSidebarMenu">
                     <menuitem id="menu_bookmarksSidebar"
                               key="viewBookmarksSidebarKb"
                               observes="viewBookmarksSidebar"/>
                     <menuitem id="menu_historySidebar"
                               key="key_gotoHistory"
                               observes="viewHistorySidebar"
                               label="&historyButton.label;"/>
+                    <menuitem id="menu_readingListSidebar"
+                              key="key_readingListSidebar"
+                              observes="readingListSidebar"
+                              label="&readingList.label;"/>
+
                     <!-- Service providers with sidebars are inserted between these two menuseperators -->
                     <menuseparator hidden="true"/>
                     <menuseparator class="social-provider-menu" hidden="true"/>
                   </menupopup>
                 </menu>
                 <menuseparator/>
                 <menu id="viewFullZoomMenu" label="&fullZoom.label;"
                       accesskey="&fullZoom.accesskey;"
new file mode 100644
--- /dev/null
+++ b/browser/base/content/browser-readinglist.js
@@ -0,0 +1,68 @@
+/*
+# 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/.
+*/
+
+let ReadingListUI = {
+  /**
+   * Initialize the ReadingList UI.
+   */
+  init() {
+    Preferences.observe("browser.readinglist.enabled", this.updateUI, this);
+    this.updateUI();
+  },
+
+  /**
+   * Un-initialize the ReadingList UI.
+   */
+  uninit() {
+    Preferences.ignore("browser.readinglist.enabled", this.updateUI, this);
+  },
+
+  /**
+   * Whether the ReadingList feature is enabled or not.
+   * @type {boolean}
+   */
+  get enabled() {
+    return Preferences.get("browser.readinglist.enabled", false);
+  },
+
+  /**
+   * Whether the ReadingList sidebar is currently open or not.
+   * @type {boolean}
+   */
+  get isSidebarOpen() {
+    return SidebarUI.isOpen && SidebarUI.currentID == "readingListSidebar";
+  },
+
+  /**
+   * Update the UI status, ensuring the UI is shown or hidden depending on
+   * whether the feature is enabled or not.
+   */
+  updateUI() {
+    let enabled = this.enabled;
+    if (!enabled) {
+      this.hideSidebar();
+    }
+
+    document.getElementById("readingListSidebar").setAttribute("hidden", !enabled);
+  },
+
+  /**
+   * Show the ReadingList sidebar.
+   * @return {Promise}
+   */
+  showSidebar() {
+    return SidebarUI.show("readingListSidebar");
+  },
+
+  /**
+   * Hide the ReadingList sidebar, if it is currently shown.
+   */
+  hideSidebar() {
+    if (this.isSidebarOpen) {
+      SidebarUI.hide();
+    }
+  },
+};
--- a/browser/base/content/browser-sets.inc
+++ b/browser/base/content/browser-sets.inc
@@ -143,16 +143,21 @@
     <!-- for both places and non-places, the sidebar lives at
          chrome://browser/content/history/history-panel.xul so there are no
          problems when switching between versions -->
     <broadcaster id="viewHistorySidebar" autoCheck="false" sidebartitle="&historyButton.label;"
                  type="checkbox" group="sidebar"
                  sidebarurl="chrome://browser/content/history/history-panel.xul"
                  oncommand="SidebarUI.toggle('viewHistorySidebar');"/>
 
+    <broadcaster id="readingListSidebar" hidden="true" autoCheck="false"
+                 sidebartitle="&readingList.label;" type="checkbox" group="sidebar"
+                 sidebarurl="chrome://browser/content/readinglist/sidebar.xhtml"
+                 oncommand="SidebarUI.toggle('readingListSidebar');"/>
+
     <broadcaster id="viewWebPanelsSidebar" autoCheck="false"
                  type="checkbox" group="sidebar" sidebarurl="chrome://browser/content/web-panels.xul"
                  oncommand="SidebarUI.toggle('viewWebPanelsSidebar');"/>
 
     <broadcaster id="bookmarkThisPageBroadcaster"
                  label="&bookmarkThisPageCmd.label;"
                  bookmarklabel="&bookmarkThisPageCmd.label;"
                  editlabel="&editThisBookmarkCmd.label;"/>
@@ -413,16 +418,21 @@
          key="&historySidebarCmd.commandKey;"
 #ifdef XP_MACOSX
          modifiers="accel,shift"
 #else
          modifiers="accel"
 #endif
          command="viewHistorySidebar"/>
 
+    <key id="key_readingListSidebar"
+         key="&readingList.sidebar.commandKey;"
+         modifiers="accel,alt"
+         command="readingListSidebar"/>
+
     <key id="key_fullZoomReduce"  key="&fullZoomReduceCmd.commandkey;"   command="cmd_fullZoomReduce"  modifiers="accel"/>
     <key                          key="&fullZoomReduceCmd.commandkey2;"  command="cmd_fullZoomReduce"  modifiers="accel"/>
     <key id="key_fullZoomEnlarge" key="&fullZoomEnlargeCmd.commandkey;"  command="cmd_fullZoomEnlarge" modifiers="accel"/>
     <key                          key="&fullZoomEnlargeCmd.commandkey2;" command="cmd_fullZoomEnlarge" modifiers="accel"/>
     <key                          key="&fullZoomEnlargeCmd.commandkey3;" command="cmd_fullZoomEnlarge" modifiers="accel"/>
     <key id="key_fullZoomReset"   key="&fullZoomResetCmd.commandkey;"    command="cmd_fullZoomReset"   modifiers="accel"/>
     <key                          key="&fullZoomResetCmd.commandkey2;"   command="cmd_fullZoomReset"   modifiers="accel"/>
 
--- a/browser/base/content/browser.js
+++ b/browser/base/content/browser.js
@@ -8,16 +8,18 @@ let Cu = Components.utils;
 let Cc = Components.classes;
 
 Cu.import("resource://gre/modules/XPCOMUtils.jsm");
 Cu.import("resource://gre/modules/NotificationDB.jsm");
 Cu.import("resource:///modules/RecentWindow.jsm");
 Cu.import("resource://gre/modules/WindowsPrefSync.jsm");
 
 
+XPCOMUtils.defineLazyModuleGetter(this, "Preferences",
+                                  "resource://gre/modules/Preferences.jsm");
 XPCOMUtils.defineLazyModuleGetter(this, "Deprecated",
                                   "resource://gre/modules/Deprecated.jsm");
 XPCOMUtils.defineLazyModuleGetter(this, "BrowserUITelemetry",
                                   "resource:///modules/BrowserUITelemetry.jsm");
 XPCOMUtils.defineLazyModuleGetter(this, "E10SUtils",
                                   "resource:///modules/E10SUtils.jsm");
 XPCOMUtils.defineLazyModuleGetter(this, "BrowserUtils",
                                   "resource://gre/modules/BrowserUtils.jsm");
@@ -220,16 +222,17 @@ let gInitialPages = [
 #include browser-eme.js
 #include browser-feeds.js
 #include browser-fullScreen.js
 #include browser-fullZoom.js
 #include browser-gestureSupport.js
 #include browser-loop.js
 #include browser-places.js
 #include browser-plugins.js
+#include browser-readinglist.js
 #include browser-safebrowsing.js
 #include browser-sidebar.js
 #include browser-social.js
 #include browser-tabview.js
 #include browser-thumbnails.js
 
 #ifdef MOZ_DATA_REPORTING
 #include browser-data-submission-info-bar.js
@@ -1366,16 +1369,17 @@ var gBrowserInit = {
         return;
       }
 
       // Enable the Restore Last Session command if needed
       RestoreLastSessionObserver.init();
 
       SocialUI.init();
       TabView.init();
+      ReadingListUI.init();
 
       // Telemetry for master-password - we do this after 5 seconds as it
       // can cause IO if NSS/PSM has not already initialized.
       setTimeout(() => {
         if (window.closed) {
           return;
         }
         let secmodDB = Cc["@mozilla.org/security/pkcs11moduledb;1"]
@@ -1474,16 +1478,18 @@ var gBrowserInit = {
     ToolbarIconColor.uninit();
 
     BrowserOnClick.uninit();
 
     DevEdition.uninit();
 
     gMenuButtonUpdateBadge.uninit();
 
+    ReadingListUI.uninit();
+
     SidebarUI.uninit();
 
     // Now either cancel delayedStartup, or clean up the services initialized from
     // it.
     if (this._boundDelayedStartup) {
       this._cancelDelayedStartup();
     } else {
       if (Win7Features)
--- a/browser/base/content/browser.xul
+++ b/browser/base/content/browser.xul
@@ -1125,17 +1125,17 @@
       <vbox id="browser-border-start" hidden="true" layer="true"/>
       <vbox id="sidebar-box" hidden="true" class="chromeclass-extrachrome">
         <sidebarheader id="sidebar-header" align="center">
           <label id="sidebar-title" persist="value" flex="1" crop="end" control="sidebar"/>
           <image id="sidebar-throbber"/>
           <toolbarbutton class="close-icon tabbable" tooltiptext="&sidebarCloseButton.tooltip;" oncommand="SidebarUI.hide();"/>
         </sidebarheader>
         <browser id="sidebar" flex="1" autoscroll="false" disablehistory="true"
-                  style="min-width: 14em; width: 18em; max-width: 36em;"/>
+                  style="min-width: 14em; width: 18em; max-width: 36em;" tooltip="aHTMLTooltip"/>
       </vbox>
 
       <splitter id="sidebar-splitter" class="chromeclass-extrachrome sidebar-splitter" hidden="true"/>
       <vbox id="appcontent" flex="1">
         <notificationbox id="high-priority-global-notificationbox"/>
         <tabbrowser id="content"
                     flex="1" contenttooltip="aHTMLTooltip"
                     tabcontainer="tabbrowser-tabs"
new file mode 100644
--- /dev/null
+++ b/browser/base/content/test/BrowserUITestUtils.jsm
@@ -0,0 +1,70 @@
+"use strict";
+
+this.EXPORTED_SYMBOLS = [
+  "BrowserUITestUtils",
+];
+
+const {classes: Cc, interfaces: Ci, utils: Cu, results: Cr} = Components;
+
+Cu.import("resource://gre/modules/Timer.jsm");
+
+
+/**
+ * Default wait period in millseconds, when waiting for the expected event to occur.
+ * @type {number}
+ */
+const DEFAULT_WAIT = 2000;
+
+
+/**
+ * Test utility functions for dealing with the browser UI DOM.
+ */
+this.BrowserUITestUtils = {
+
+  /**
+   * Waits a specified number of miliseconds for a specified event to be
+   * fired on a specified element.
+   *
+   * Usage:
+   *    let receivedEvent = BrowserUITestUtils.waitForEvent(element, "eventName");
+   *    // Do some processing here that will cause the event to be fired
+   *    // ...
+   *    // Now yield until the Promise is fulfilled
+   *    yield receivedEvent;
+   *    if (receivedEvent && !(receivedEvent instanceof Error)) {
+   *      receivedEvent.msg == "eventName";
+   *      // ...
+   *    }
+   *
+   * @param {Element} subject - The element that should receive the event.
+   * @param {string} eventName - The event to wait for.
+   * @param {number} timeoutMs - The number of miliseconds to wait before giving up.
+   * @param {Element} target - Expected target of the event.
+   * @returns {Promise} A Promise that resolves to the received event, or
+   *                    rejects with an Error.
+   */
+  waitForEvent(subject, eventName, timeoutMs, target) {
+    return new Promise((resolve, reject) => {
+      function listener(event) {
+        if (target && target !== event.target) {
+          return;
+        }
+
+        subject.removeEventListener(eventName, listener);
+        clearTimeout(timerID);
+        resolve(event);
+      }
+
+      timeoutMs = timeoutMs || DEFAULT_WAIT;
+      let stack = new Error().stack;
+
+      let timerID = setTimeout(() => {
+        subject.removeEventListener(eventName, listener);
+        reject(new Error(`${eventName} event timeout at ${stack}`));
+      }, timeoutMs);
+
+
+      subject.addEventListener(eventName, listener);
+    });
+  },
+};
--- a/browser/base/moz.build
+++ b/browser/base/moz.build
@@ -1,16 +1,20 @@
 # -*- Mode: python; c-basic-offset: 4; indent-tabs-mode: nil; tab-width: 40 -*-
 # vim: set filetype=python:
 # 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/.
 
 SPHINX_TREES['sslerrorreport'] = 'content/docs/sslerrorreport'
 
+TESTING_JS_MODULES += [
+    'content/test/BrowserUITestUtils.jsm',
+]
+
 MOCHITEST_MANIFESTS += [
     'content/test/general/mochitest.ini',
 ]
 
 MOCHITEST_CHROME_MANIFESTS += [
     'content/test/chrome/chrome.ini',
 ]
 
--- a/browser/components/customizableui/test/browser_988072_sidebar_events.js
+++ b/browser/components/customizableui/test/browser_988072_sidebar_events.js
@@ -48,19 +48,23 @@ function addWidget() {
 
 function removeWidget() {
   CustomizableUI.removeWidgetFromArea("sidebar-button");
   PanelUI.enableSingleSubviewPanelAnimations();
 }
 
 // Filters out the trailing menuseparators from the sidebar list
 function getSidebarList() {
-  let sidebars = [...gSidebarMenu.children];
-  while (sidebars[sidebars.length - 1].localName == "menuseparator")
-    sidebars.pop();
+  let sidebars = [...gSidebarMenu.children].filter(sidebar => {
+    if (sidebar.localName == "menuseparator")
+      return false;
+    if (sidebar.getAttribute("hidden") == "true")
+      return false;
+    return true;
+  });
   return sidebars;
 }
 
 function compareElements(original, displayed) {
   let attrs = ["label", "key", "disabled", "hidden", "origin", "image", "checked"];
   for (let attr of attrs) {
     is(displayed.getAttribute(attr), original.getAttribute(attr), "Should have the same " + attr + " attribute");
   }
--- a/browser/components/moz.build
+++ b/browser/components/moz.build
@@ -10,16 +10,17 @@ DIRS += [
     'dirprovider',
     'downloads',
     'feeds',
     'loop',
     'migration',
     'places',
     'preferences',
     'privatebrowsing',
+    'readinglist',
     'search',
     'sessionstore',
     'shell',
     'selfsupport',
     'tabview',
     'uitour',
     'translation',
 ]
new file mode 100644
--- /dev/null
+++ b/browser/components/readinglist/ReadingList.jsm
@@ -0,0 +1,356 @@
+/* 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";
+
+this.EXPORTED_SYMBOLS = ["ReadingList"];
+
+const {classes: Cc, interfaces: Ci, utils: Cu, results: Cr} = Components;
+
+
+Cu.import("resource://gre/modules/Services.jsm");
+Cu.import("resource://gre/modules/XPCOMUtils.jsm");
+Cu.import("resource://gre/modules/Preferences.jsm");
+Cu.import("resource://gre/modules/Log.jsm");
+
+
+(function() {
+  let parentLog = Log.repository.getLogger("readinglist");
+  parentLog.level = Preferences.get("browser.readinglist.logLevel", Log.Level.Warn);
+  Preferences.observe("browser.readinglist.logLevel", value => {
+    parentLog.level = value;
+  });
+  let formatter = new Log.BasicFormatter();
+  parentLog.addAppender(new Log.ConsoleAppender(formatter));
+  parentLog.addAppender(new Log.DumpAppender(formatter));
+})();
+
+let log = Log.repository.getLogger("readinglist.api");
+
+/**
+ * Represents an item in the Reading List.
+ * @constructor
+ * @see https://github.com/mozilla-services/readinglist/wiki/API-Design-proposal#data-model
+ */
+function Item(data) {
+  this._data = data;
+}
+
+Item.prototype = {
+  /**
+   * UUID
+   * @type {string}
+   */
+  get id() {
+    return this._data.id;
+  },
+
+  /**
+   * Server timestamp
+   * @type {string}
+   */
+  get lastModified() {
+    return this._data.last_modified;
+  },
+
+  /**
+   * @type {nsIURL}
+   */
+  get originalUrl() {
+    return Services.io.newURI(this._data.url, null, null);
+  },
+
+  /**
+   * @type {string}
+   */
+  get originalTitle() {
+    return this._data.title || "";
+  },
+
+  /**
+   * @type {nsIURL}
+   */
+  get resolvedUrl() {
+    return Services.io.newURI(this._data.resolved_url || this._data.url, null, null);
+  },
+
+  /**
+   * @type {string}
+   */
+  get resolvedTitle() {
+    return this._data.resolved_title || this.originalTitle;
+  },
+
+  /**
+   * @type {string}
+   */
+  get excerpt() {
+    return this._data.excerpt || "";
+  },
+
+  /**
+   * @type {ItemStates}
+   */
+  get state() {
+    return ReadingList.ItemStates[this._data.state] || ReadingList.ItemStates.OK;
+  },
+
+  /**
+   * @type {boolean}
+   */
+  get isFavorite() {
+    return !!this._data.favorite;
+  },
+
+  /**
+   * @type {boolean}
+   */
+  get isArticle() {
+    return !!this._data.is_article;
+  },
+
+  /**
+   * @type {number}
+   */
+  get wordCount() {
+    return this._data.word_count || 0;
+  },
+
+  /**
+   * @type {boolean}
+   */
+  get isUnread() {
+    return !!this._data.unread;
+  },
+
+  /**
+   * Device name
+   * @type {string}
+   */
+  get addedBy() {
+    return this._data.added_by;
+  },
+
+  /**
+   * @type {Date}
+   */
+  get addedOn() {
+    return new Date(this._data.added_on);
+  },
+
+  /**
+   * @type {Date}
+   */
+  get storedOn() {
+    return new Date(this._data.stored_on);
+  },
+
+  /**
+   * Device name
+   * @type {string}
+   */
+  get markedReadBy() {
+    return this._data.marked_read_by;
+  },
+
+  /**
+   * @type {Date}
+   */
+  get markedReadOn() {
+    return new date(this._data.marked_read_on);
+  },
+
+  /**
+   * @type {number}
+   */
+  get readPosition() {
+    return this._data.read_position;
+  },
+
+  // Data not specified by the current server API
+
+  /**
+   * Array of scraped or captured summary images for this page.
+   * TODO: Implement this.
+   * @type {[nsIURL]}
+   */
+  get images() {
+    return [];
+  },
+
+  /**
+   * Favicon for this site.
+   * @type {nsIURL}
+   * TODO: Generate moz-anno: URI for favicon.
+   */
+  get favicon() {
+    return null;
+  },
+
+  // Helpers
+
+  /**
+   * Alias for resolvedUrl.
+   * TODO: This url/resolvedUrl alias makes it feel like the server API hasn't got this right.
+   */
+  get url() {
+    return this.resolvedUrl;
+  },
+  /**
+   * Alias for resolvedTitle
+   */
+  get title() {
+    return this.resolvedTitle;
+  },
+
+  /**
+   * Domain portion of the URL, with prefixes stripped. For display purposes.
+   * @type {string}
+   */
+  get domain() {
+    let host = this.resolvedUrl.host;
+    if (host.startsWith("www.")) {
+      host = host.slice(4);
+    }
+    return host;
+  },
+
+  /**
+   * Convert this Item to a string representation.
+   */
+  toString() {
+    return `[Item url=${this.url.spec}]`;
+  },
+
+  /**
+   * Get the value that should be used for a JSON representation of this Item.
+   */
+  toJSON() {
+    return this._data;
+  },
+};
+
+
+let ItemStates = {
+  OK: Symbol("ok"),
+  ARCHIVED: Symbol("archived"),
+  DELETED: Symbol("deleted"),
+};
+
+
+this.ReadingList = {
+  Item: Item,
+  ItemStates: ItemStates,
+
+  _listeners: new Set(),
+  _items: [],
+
+  /**
+   * Initialize the ReadingList component.
+   */
+  _init() {
+    log.debug("Init");
+
+    // Initialize mock data
+    let mockData = JSON.parse(Preferences.get("browser.readinglist.mockData", "[]"));
+    for (let itemData of mockData) {
+      this._items.push(new Item(itemData));
+    }
+  },
+
+  /**
+   * Add an event listener.
+   * @param {object} listener - Listener object to start notifying.
+   */
+  addListener(listener) {
+    this._listeners.add(listener);
+  },
+
+  /**
+   * Remove a specified event listener.
+   * @param {object} listener - Listener object to stop notifying.
+   */
+  removeListener(listener) {
+    this._listeners.delete(listener);
+  },
+
+  /**
+   * Notify all registered event listeners of an event.
+   * @param {string} eventName - Event name, which will be used as a method name
+   *                             on listeners to call.
+   */
+  _notifyListeners(eventName, ...args) {
+    for (let listener of this._listeners) {
+      if (typeof listener[eventName] != "function") {
+        continue;
+      }
+
+      try {
+        listener[eventName](...args);
+      } catch (e) {
+        log.error(`Error calling listener.${eventName}`, e);
+      }
+    }
+  },
+
+  /**
+   * Fetch the number of items that match a set of given conditions.
+   * TODO: Implement filtering, sorting, etc. Needs backend storage work.
+   *
+   * @param {Object} conditions Object specifying a set of conditions for
+   *                            filtering items.
+   * @return {Promise}
+   * @resolves {number}
+   */
+  getNumItems(conditions = {unread: false}) {
+    return new Promise((resolve, reject) => {
+      resolve(this._items.length);
+    });
+  },
+
+  /**
+   * Fetch items matching a set of conditions, in a sorted list.
+   * TODO: Implement filtering, sorting, etc. Needs backend storage work.
+   *
+   * @return {Promise}
+   * @resolves {[Item]}
+   */
+  getItems(options = {sort: "addedOn", conditions: {unread: false}}) {
+    return new Promise((resolve, reject) => {
+      resolve([...this._items]);
+    });
+  },
+
+  /**
+   * Find an item based on its ID.
+   * TODO: Implement. Needs backend storage work.
+   *
+   * @return {Promise}
+   * @resolves {Item}
+   */
+  getItemByID(url) {
+    return new Promise((resolve, reject) => {
+      resolve(null);
+    });
+  },
+
+  /**
+   * Find an item based on its URL.
+   *
+   * TODO: Implement. Needs backend storage work.
+   * TODO: Does this match original or resolved URL, or both?
+   * TODO: Should this just be a generic findItem API?
+   *
+   * @return {Promise}
+   * @resolves {Item}
+   */
+  getItemByURL(url) {
+    return new Promise((resolve, reject) => {
+      resolve(null);
+    });
+  },
+};
+
+
+ReadingList._init();
new file mode 100644
--- /dev/null
+++ b/browser/components/readinglist/jar.mn
@@ -0,0 +1,7 @@
+# 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/.
+
+browser.jar:
+  content/browser/readinglist/sidebar.xhtml
+  content/browser/readinglist/sidebar.js
new file mode 100644
--- /dev/null
+++ b/browser/components/readinglist/moz.build
@@ -0,0 +1,15 @@
+# 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/.
+
+JAR_MANIFESTS += ['jar.mn']
+
+EXTRA_JS_MODULES.readinglist += [
+    'ReadingList.jsm',
+]
+
+TESTING_JS_MODULES += [
+    'test/ReadingListTestUtils.jsm',
+]
+
+BROWSER_CHROME_MANIFESTS += ['test/browser/browser.ini']
new file mode 100644
--- /dev/null
+++ b/browser/components/readinglist/sidebar.js
@@ -0,0 +1,403 @@
+/* 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";
+
+const {classes: Cc, interfaces: Ci, utils: Cu, results: Cr} = Components;
+
+Cu.import("resource://gre/modules/Services.jsm");
+Cu.import("resource://gre/modules/XPCOMUtils.jsm");
+Cu.import("resource:///modules/readinglist/ReadingList.jsm");
+
+let log = Cu.import("resource://gre/modules/Log.jsm", {})
+            .Log.repository.getLogger("readinglist.sidebar");
+
+
+let RLSidebar = {
+  /**
+   * Container element for all list item elements.
+   * @type {Element}
+   */
+  list: null,
+
+  /**
+   * <template> element used for constructing list item elements.
+   * @type {Element}
+   */
+  itemTemplate: null,
+
+  /**
+   * Map of ReadingList Item objects, keyed by their ID.
+   * @type {Map}
+   */
+  itemsById: new Map(),
+  /**
+   * Map of list item elements, keyed by their corresponding Item's ID.
+   * @type {Map}
+   */
+  itemNodesById: new Map(),
+
+  /**
+   * Initialize the sidebar UI.
+   */
+  init() {
+    log.debug("Initializing");
+
+    addEventListener("unload", () => this.uninit());
+
+    this.list = document.getElementById("list");
+    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);
+
+    this.ensureListItems();
+    ReadingList.addListener(this);
+
+    let initEvent = new CustomEvent("Initialized", {bubbles: true});
+    document.documentElement.dispatchEvent(initEvent);
+  },
+
+  /**
+   * Un-initialize the sidebar UI.
+   */
+  uninit() {
+    log.debug("Shutting down");
+
+    ReadingList.removeListener(this);
+  },
+
+  /**
+   * 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 {Readinglist.Item} item - Item that was added.
+   */
+  onItemAdded(item) {
+    log.trace(`onItemAdded: ${item}`);
+
+    let itemNode = document.importNode(this.itemTemplate.content, true).firstElementChild;
+    this.updateItem(item, itemNode);
+    this.list.appendChild(itemNode);
+    this.itemNodesById.set(item.id, itemNode);
+    this.itemsById.set(item.id, item);
+  },
+
+  /**
+   * Handle an item being deleted from the ReadingList.
+   * @param {ReadingList.Item} item - Item that was deleted.
+   */
+  onItemDeleted(item) {
+    log.trace(`onItemDeleted: ${item}`);
+
+    let itemNode = this.itemNodesById.get(item.id);
+    itemNode.remove();
+    this.itemNodesById.delete(item.id);
+    this.itemsById.delete(item.id);
+    // TODO: ensureListItems doesn't yet cope with needing to add one item.
+    //this.ensureListItems();
+  },
+
+  /**
+   * Handle an item in the ReadingList having any of its properties changed.
+   * @param {ReadingList.Item} item - Item that was updated.
+   */
+  onItemUpdated(item) {
+    log.trace(`onItemUpdated: ${item}`);
+
+    let itemNode = this.itemNodesById.get(item.id);
+    if (!itemNode)
+      return;
+
+    this.updateItem(item, itemNode);
+  },
+
+  /**
+   * Update the element representing an item, ensuring it's in sync with the
+   * underlying data.
+   * @param {ReadingList.Item} 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.spec}`);
+
+    itemNode.querySelector(".item-title").textContent = item.title;
+    itemNode.querySelector(".item-domain").textContent = item.domain;
+  },
+
+  /**
+   * Ensure that the list is populated with the correct items.
+   */
+  ensureListItems() {
+    ReadingList.getItems().then(items => {
+      for (let item of items) {
+        // TODO: Should be batch inserting via DocumentFragment
+        try {
+          this.onItemAdded(item);
+        } catch (e) {
+          log.warn("Error adding item", e);
+        }
+      }
+    });
+  },
+
+  /**
+   * Get the number of items currently displayed in the list.
+   * @type {number}
+   */
+  get numItems() {
+    return this.list.childElementCount;
+  },
+
+  /**
+   * The currently active element in the list.
+   * @type {Element}
+   */
+  get activeItem() {
+    return document.querySelector("#list > .item.active");
+  },
+
+  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;
+      }
+    }
+
+    let prevItem = document.querySelector("#list > .item.active");
+    if (prevItem) {
+      prevItem.classList.remove("active");
+    }
+
+    if (node) {
+      node.classList.add("active");
+    }
+
+    let event = new CustomEvent("ActiveItemChanged", {bubbles: true});
+    this.list.dispatchEvent(event);
+  },
+
+  /**
+   * The currently selected item in the list.
+   * @type {Element}
+   */
+  get selectedItem() {
+    return document.querySelector("#list > .item.selected");
+  },
+
+  set selectedItem(node) {
+    if (node && node.parentNode != this.list) {
+      log.error(`Unable to set selectedItem to invalid node ${node}`);
+      return;
+    }
+
+    log.debug(`Setting activeItem: ${node ? node.id : null}`);
+
+    let prevItem = document.querySelector("#list > .item.selected");
+    if (prevItem) {
+      prevItem.classList.remove("selected");
+    }
+
+    if (node) {
+      node.classList.add("selected");
+      let itemId = this.getItemIdFromNode(node);
+      this.list.setAttribute("aria-activedescendant", "item-" + itemId);
+    } else {
+      this.list.removeAttribute("aria-activedescendant");
+    }
+
+    let event = new CustomEvent("SelectedItemChanged", {bubbles: true});
+    this.list.dispatchEvent(event);
+  },
+
+  /**
+   * The index of the currently selected item in the list.
+   * @type {number}
+   */
+  get selectedIndex() {
+    for (let i = 0; i < this.numItems; i++) {
+      let item = this.list.children.item(i);
+      if (!item) {
+        break;
+      }
+      if (item.classList.contains("selected")) {
+        return i;
+      }
+    }
+    return -1;
+  },
+
+  set selectedIndex(index) {
+    log.debug(`Setting selectedIndex: ${index}`);
+
+    if (index == -1) {
+      this.selectedItem = null;
+      return;
+    }
+
+    let item = this.list.children.item(index);
+    if (!item) {
+      log.warn(`Unable to set selectedIndex to invalid index ${index}`);
+      return;
+    }
+    this.selectedItem = item;
+  },
+
+  /**
+   * Open a given URL. The event is used to determine where it should be opened
+   * (current tab, new tab, new window).
+   * @param {string} url - URL to open.
+   * @param {Event} event - KeyEvent or MouseEvent that triggered this action.
+   */
+  openURL(url, event) {
+    // TODO: Disabled while working on the listbox mechanics.
+    log.debug(`Opening page ${url}`);
+    return;
+
+    let mainWindow = window.QueryInterface(Ci.nsIInterfaceRequestor)
+                           .getInterface(Ci.nsIWebNavigation)
+                           .QueryInterface(Ci.nsIDocShellTreeItem)
+                           .rootTreeItem
+                           .QueryInterface(Ci.nsIInterfaceRequestor)
+                           .getInterface(Ci.nsIDOMWindow);
+    mainWindow.openUILink(url, event);
+  },
+
+  /**
+   * Get the ID of the Item associated with a given list item element.
+   * @param {element} node - List item element to get an ID for.
+   * @return {string} Assocated Item ID.
+   */
+  getItemIdFromNode(node) {
+    let id = node.getAttribute("id");
+    if (id && id.startsWith("item-")) {
+      return id.slice(5);
+    }
+
+    return null;
+  },
+
+  /**
+   * Get the Item associated with a given list item element.
+   * @param {element} node - List item element to get an Item for.
+   * @return {string} Associated Item.
+   */
+  getItemFromNode(node) {
+    let itemId = this.getItemIdFromNode(node);
+    if (!itemId) {
+      return null;
+    }
+
+    return this.itemsById.get(itemId);
+  },
+
+  /**
+   * Open the active item in the list.
+   * @param {Event} event - Event triggering this.
+   */
+  openActiveItem(event) {
+    let itemNode = this.activeItem;
+    if (!itemNode) {
+      return;
+    }
+
+    let item = this.getItemFromNode(itemNode);
+    this.openURL(item.url.spec, event);
+  },
+
+  /**
+   * Find the parent item element, from a given child element.
+   * @param {Element} node - Child element.
+   * @return {Element} Element for the item, or null if not found.
+   */
+  findParentItemNode(node) {
+    while (node && node != this.list && node != document.documentElement &&
+           !node.classList.contains("item")) {
+      node = node.parentNode;
+    }
+
+    if (node != this.list && node != document.documentElement) {
+      return node;
+    }
+
+    return null;
+  },
+
+  /**
+   * Handle a click event on the list box.
+   * @param {Event} event - Triggering event.
+   */
+  onListClick(event) {
+    let itemNode = this.findParentItemNode(event.target);
+    if (!itemNode)
+      return;
+
+    this.activeItem = itemNode;
+    this.openActiveItem(event);
+  },
+
+  /**
+   * Handle a mousemove event over the list box.
+   * @param {Event} event - Triggering event.
+   */
+  onListMouseMove(event) {
+    let itemNode = this.findParentItemNode(event.target);
+    if (!itemNode)
+      return;
+
+    this.selectedItem = itemNode;
+  },
+
+  /**
+   * Handle a keydown event on the list box.
+   * @param {Event} event - Triggering event.
+   */
+  onListKeyDown(event) {
+    if (event.keyCode == KeyEvent.DOM_VK_DOWN) {
+      // TODO: Refactor this so we pass a direction to a generic method.
+      // See autocomplete.xml's getNextIndex
+      event.preventDefault();
+      let index = this.selectedIndex + 1;
+      if (index >= this.numItems) {
+        index = 0;
+      }
+
+      this.selectedIndex = index;
+      this.selectedItem.focus();
+    } else if (event.keyCode == KeyEvent.DOM_VK_UP) {
+      event.preventDefault();
+
+      let index = this.selectedIndex - 1;
+      if (index < 0) {
+        index = this.numItems - 1;
+      }
+
+      this.selectedIndex = index;
+      this.selectedItem.focus();
+    } else if (event.keyCode == KeyEvent.DOM_VK_RETURN) {
+      let selectedItem = this.selectedItem;
+      if (selectedItem) {
+        this.activeItem = this.selectedItem;
+        this.openActiveItem(event);
+      }
+    }
+  },
+};
+
+
+addEventListener("DOMContentLoaded", () => RLSidebar.init());
new file mode 100644
--- /dev/null
+++ b/browser/components/readinglist/sidebar.xhtml
@@ -0,0 +1,30 @@
+<?xml version="1.0" encoding="UTF-8"?>
+<!-- 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/. -->
+
+<!DOCTYPE html [
+  <!ENTITY % browserDTD SYSTEM "chrome://browser/locale/browser.dtd">
+  %browserDTD;
+]>
+<html xmlns="http://www.w3.org/1999/xhtml">
+  <head>
+    <script src="chrome://browser/content/readinglist/sidebar.js" type="application/javascript;version=1.8"></script>
+    <link rel="stylesheet" type="text/css" media="all" href="chrome://browser/skin/readinglist/sidebar.css"/>
+    <!-- <title>&readingList.label;</title> -->
+  </head>
+
+  <body role="application">
+    <template id="item-template">
+      <div class="item" role="option" tabindex="-1">
+        <div class="item-thumb-container"></div>
+        <div class="item-summary-container">
+          <div class="item-title"></div>
+          <div class="item-domain"></div>
+        </div>
+      </div>
+    </template>
+
+    <div id="list" role="listbox" tabindex="1"></div>
+  </body>
+</html>
new file mode 100644
--- /dev/null
+++ b/browser/components/readinglist/test/ReadingListTestUtils.jsm
@@ -0,0 +1,159 @@
+"use strict";
+
+this.EXPORTED_SYMBOLS = [
+  "ReadingListTestUtils",
+];
+
+const {classes: Cc, interfaces: Ci, utils: Cu, results: Cr} = Components;
+
+Cu.import("resource://gre/modules/XPCOMUtils.jsm");
+Cu.import("resource://gre/modules/Services.jsm");
+Cu.import("resource://gre/modules/Preferences.jsm");
+Cu.import("resource:///modules/readinglist/ReadingList.jsm");
+
+
+/** Preference name controlling whether the ReadingList feature is enabled/disabled. */
+const PREF_RL_ENABLED = "browser.readinglist.enabled";
+
+
+/**
+ * Utilities for testing the ReadingList sidebar.
+ */
+function SidebarUtils(window, assert) {
+  this.window = window;
+  this.Assert = assert;
+}
+
+SidebarUtils.prototype = {
+  /**
+   * Reference to the RLSidebar object controlling the ReadingList sidebar UI.
+   * @type {object}
+   */
+  get RLSidebar() {
+    return this.window.SidebarUI.browser.contentWindow.RLSidebar;
+  },
+
+  /**
+   * Reference to the list container element in the sidebar.
+   * @type {Element}
+   */
+  get list() {
+    return this.RLSidebar.list;
+  },
+
+  /**
+   * Check that the number of elements in the list matches the expected count.
+   * @param {number} count - Expected number of items.
+   */
+  expectNumItems(count) {
+    this.Assert.equal(this.list.childElementCount, count,
+                      "Should have expected number of items in the sidebar list");
+  },
+
+  /**
+   * Check all items in the sidebar list, ensuring the DOM matches the data.
+   */
+  checkAllItems() {
+    for (let itemNode of this.list.children) {
+      this.checkSidebarItem(itemNode);
+    }
+  },
+
+  /**
+   * Run a series of sanity checks for an element in the list associated with
+   * an Item, ensuring the DOM matches the data.
+   */
+  checkItem(node) {
+    let item = this.RLSidebar.getItemFromNode(node);
+
+    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,
+                      "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 {
+      this.Assert.notEqual(selectedItem, null, "selectedItem should not be null");
+      let selectedId = this.RLSidebar.getItemIdFromNode(selectedItem);
+      this.Assert.equal(itemId, selectedId, "Should have currect item selected");
+    }
+  },
+
+  expectActiveId(itemId) {
+    let activeItem = this.RLSidebar.activeItem;
+    if (itemId == null) {
+      this.Assert.equal(activeItem, null, "Should have no active item");
+    } else {
+      this.Assert.notEqual(activeItem, null, "activeItem should not be null");
+      let activeId = this.RLSidebar.getItemIdFromNode(activeItem);
+      this.Assert.equal(itemId, activeId, "Should have correct item active");
+    }
+  },
+};
+
+
+/**
+ * Utilities for testing the ReadingList.
+ */
+this.ReadingListTestUtils = {
+  /**
+   * Whether the ReadingList feature is enabled or not.
+   * @type {boolean}
+   */
+  get enabled() {
+    return Preferences.get(PREF_RL_ENABLED, false);
+  },
+  set enabled(value) {
+    Preferences.set(PREF_RL_ENABLED, !!value);
+  },
+
+  /**
+   * Utilities for testing the ReadingList sidebar.
+   */
+  SidebarUtils: SidebarUtils,
+
+  /**
+   * Synthetically add an item to the ReadingList.
+   * @param {object|[object]} data - Object or array of objects to pass to the
+   *                                 Item constructor.
+   * @return {Promise} Promise that gets fulfilled with the item or items added.
+   */
+  addItem(data) {
+    if (Array.isArray(data)) {
+      let promises = [];
+      for (let itemData of data) {
+        promises.push(this.addItem(itemData));
+      }
+      return Promise.all(promises);
+    }
+
+    return new Promise(resolve => {
+      let item = new ReadingList.Item(data);
+      ReadingList._items.push(item);
+      ReadingList._notifyListeners("onItemAdded", item);
+      resolve(item);
+    });
+  },
+
+  /**
+   * Cleanup all data, resetting to a blank state.
+   */
+  cleanup() {
+    return new Promise(resolve => {
+      ReadingList._items = [];
+      ReadingList._listeners.clear();
+      Preferences.reset(PREF_RL_ENABLED);
+      resolve();
+    });
+  },
+};
new file mode 100644
--- /dev/null
+++ b/browser/components/readinglist/test/browser/browser.ini
@@ -0,0 +1,7 @@
+[DEFAULT]
+support-files =
+  head.js
+
+[browser_ui_enable_disable.js]
+[browser_sidebar_list.js]
+[browser_sidebar_mouse_nav.js]
new file mode 100644
--- /dev/null
+++ b/browser/components/readinglist/test/browser/browser_sidebar_list.js
@@ -0,0 +1,53 @@
+/**
+ * This tests the basic functionality of the sidebar to list items.
+ */
+
+add_task(function*() {
+  registerCleanupFunction(function*() {
+    ReadingListUI.hideSidebar();
+    yield RLUtils.cleanup();
+  });
+
+  RLUtils.enabled = true;
+
+  yield ReadingListUI.showSidebar();
+  let RLSidebar = RLSidebarUtils.RLSidebar;
+  let sidebarDoc = SidebarUI.browser.contentDocument;
+  Assert.equal(RLSidebar.numItems, 0, "Should start with no items");
+  Assert.equal(RLSidebar.activeItem, null, "Should start with no active item");
+  Assert.equal(RLSidebar.activeItem, null, "Should start with no selected item");
+
+  info("Adding first item");
+  yield RLUtils.addItem({
+    id: "c3502a49-bcef-4a94-b222-d4834463de33",
+    url: "http://example.com/article1",
+    title: "Article 1",
+  });
+  RLSidebarUtils.expectNumItems(1);
+
+  info("Adding more items");
+  yield RLUtils.addItem([{
+    id: "e054f5b7-1f4f-463f-bb96-d64c02448c31",
+    url: "http://example.com/article2",
+    title: "Article 2",
+  }, {
+    id: "4207230b-2364-4e97-9587-01312b0ce4e6",
+    url: "http://example.com/article3",
+    title: "Article 3",
+  }]);
+  RLSidebarUtils.expectNumItems(3);
+
+  info("Closing sidebar");
+  ReadingListUI.hideSidebar();
+
+  info("Adding another item");
+  yield RLUtils.addItem({
+    id: "dae0e855-607e-4df3-b27f-73a5e35c94fe",
+    url: "http://example.com/article4",
+    title: "Article 4",
+  });
+
+  info("Re-eopning sidebar");
+  yield ReadingListUI.showSidebar();
+  RLSidebarUtils.expectNumItems(4);
+});
new file mode 100644
--- /dev/null
+++ b/browser/components/readinglist/test/browser/browser_sidebar_mouse_nav.js
@@ -0,0 +1,82 @@
+/**
+ * Test mouse navigation for selecting items in the sidebar.
+ */
+
+
+function mouseInteraction(mouseEvent, responseEvent, itemNode) {
+  let eventPromise = BrowserUITestUtils.waitForEvent(RLSidebarUtils.list, responseEvent);
+  let details = {};
+  if (mouseEvent != "click") {
+    details.type = mouseEvent;
+  }
+
+  EventUtils.synthesizeMouseAtCenter(itemNode, details, itemNode.ownerDocument.defaultView);
+  return eventPromise;
+}
+
+add_task(function*() {
+  registerCleanupFunction(function*() {
+    ReadingListUI.hideSidebar();
+    yield RLUtils.cleanup();
+  });
+
+  RLUtils.enabled = true;
+
+  let itemData = [{
+    id: "00bd24c7-3629-40b0-acde-37aa81768735",
+    url: "http://example.com/article1",
+    title: "Article 1",
+  }, {
+    id: "28bf7f19-cf94-4ceb-876a-ac1878342e0d",
+    url: "http://example.com/article2",
+    title: "Article 2",
+  }, {
+    id: "7e5064ea-f45d-4fc7-8d8c-c067b7781e78",
+    url: "http://example.com/article3",
+    title: "Article 3",
+  }, {
+    id: "8e72a472-8db8-4904-ba39-9672f029e2d0",
+    url: "http://example.com/article4",
+    title: "Article 4",
+  }, {
+    id: "8d332744-37bc-4a1a-a26b-e9953b9f7d91",
+    url: "http://example.com/article5",
+    title: "Article 5",
+  }];
+  yield RLUtils.addItem(itemData);
+
+  yield ReadingListUI.showSidebar();
+  RLSidebarUtils.expectNumItems(5);
+  RLSidebarUtils.expectSelectedId(null);
+  RLSidebarUtils.expectActiveId(null);
+
+  info("Mouse move over item 1");
+  yield mouseInteraction("mousemove", "SelectedItemChanged", RLSidebarUtils.list.children[0]);
+  RLSidebarUtils.expectSelectedId(itemData[0].id);
+  RLSidebarUtils.expectActiveId(null);
+
+  info("Mouse move over item 2");
+  yield mouseInteraction("mousemove", "SelectedItemChanged", RLSidebarUtils.list.children[1]);
+  RLSidebarUtils.expectSelectedId(itemData[1].id);
+  RLSidebarUtils.expectActiveId(null);
+
+  info("Mouse move over item 5");
+  yield mouseInteraction("mousemove", "SelectedItemChanged", RLSidebarUtils.list.children[4]);
+  RLSidebarUtils.expectSelectedId(itemData[4].id);
+  RLSidebarUtils.expectActiveId(null);
+
+  info("Mouse move over item 1 again");
+  yield mouseInteraction("mousemove", "SelectedItemChanged", RLSidebarUtils.list.children[0]);
+  RLSidebarUtils.expectSelectedId(itemData[0].id);
+  RLSidebarUtils.expectActiveId(null);
+
+  info("Mouse click on item 1");
+  yield mouseInteraction("click", "ActiveItemChanged", RLSidebarUtils.list.children[0]);
+  RLSidebarUtils.expectSelectedId(itemData[0].id);
+  RLSidebarUtils.expectActiveId(itemData[0].id);
+
+  info("Mouse click on item 3");
+  yield mouseInteraction("click", "ActiveItemChanged", RLSidebarUtils.list.children[2]);
+  RLSidebarUtils.expectSelectedId(itemData[2].id);
+  RLSidebarUtils.expectActiveId(itemData[2].id);
+});
new file mode 100644
--- /dev/null
+++ b/browser/components/readinglist/test/browser/browser_ui_enable_disable.js
@@ -0,0 +1,49 @@
+/**
+ * Test enabling/disabling the entire ReadingList feature via the
+ * browser.readinglist.enabled preference.
+ */
+
+function checkRLState() {
+  let enabled = RLUtils.enabled;
+  info("Checking ReadingList UI is " + (enabled ? "enabled" : "disabled"));
+
+  let sidebarBroadcaster = document.getElementById("readingListSidebar");
+  let sidebarMenuitem = document.getElementById("menu_readingListSidebar");
+
+  if (enabled) {
+    Assert.notEqual(sidebarBroadcaster.getAttribute("hidden"), "true",
+                    "Sidebar broadcaster should not be hidden");
+    Assert.notEqual(sidebarMenuitem.getAttribute("hidden"), "true",
+                    "Sidebar menuitem should be visible");
+  } else {
+    Assert.equal(sidebarBroadcaster.getAttribute("hidden"), "true",
+                 "Sidebar broadcaster should be hidden");
+    Assert.equal(sidebarMenuitem.getAttribute("hidden"), "true",
+                 "Sidebar menuitem should be hidden");
+    Assert.equal(ReadingListUI.isSidebarOpen, false,
+                 "ReadingListUI should not think sidebar is open");
+  }
+
+  if (!enabled) {
+    Assert.equal(SidebarUI.isOpen, false, "Sidebar should not be open");
+  }
+}
+
+add_task(function*() {
+  info("Start with ReadingList disabled");
+  RLUtils.enabled = false;
+  checkRLState();
+  info("Enabling ReadingList");
+  RLUtils.enabled = true;
+  checkRLState();
+
+  info("Opening ReadingList sidebar");
+  yield ReadingListUI.showSidebar();
+  Assert.ok(SidebarUI.isOpen, "Sidebar should be open");
+  Assert.equal(SidebarUI.currentID, "readingListSidebar", "Sidebar should have ReadingList loaded");
+
+  info("Disabling ReadingList");
+  RLUtils.enabled = false;
+  Assert.ok(!SidebarUI.isOpen, "Sidebar should be closed");
+  checkRLState();
+});
new file mode 100644
--- /dev/null
+++ b/browser/components/readinglist/test/browser/head.js
@@ -0,0 +1,15 @@
+XPCOMUtils.defineLazyModuleGetter(this, "ReadingList",
+                                  "resource:///modules/readinglist/ReadingList.jsm");
+XPCOMUtils.defineLazyModuleGetter(this, "ReadingListTestUtils",
+                                  "resource://testing-common/ReadingListTestUtils.jsm");
+XPCOMUtils.defineLazyModuleGetter(this, "BrowserUITestUtils",
+                                  "resource://testing-common/BrowserUITestUtils.jsm");
+
+
+XPCOMUtils.defineLazyGetter(this, "RLUtils", () => {
+  return ReadingListTestUtils;
+});
+
+XPCOMUtils.defineLazyGetter(this, "RLSidebarUtils", () => {
+  return new RLUtils.SidebarUtils(window, Assert);
+});
--- a/browser/locales/en-US/chrome/browser/browser.dtd
+++ b/browser/locales/en-US/chrome/browser/browser.dtd
@@ -833,10 +833,12 @@ just addresses the organization to follo
 <!ENTITY processHang.terminateScript.accessKey    "S">
 <!ENTITY processHang.debugScript.label            "Debug Script">
 <!ENTITY processHang.debugScript.accessKey        "D">
 <!ENTITY processHang.terminatePlugin.label        "Kill Plugin">
 <!ENTITY processHang.terminatePlugin.accessKey    "P">
 <!ENTITY processHang.terminateProcess.label       "Kill Web Process">
 <!ENTITY processHang.terminateProcess.accessKey   "K">
 
+<!ENTITY readingList.label                        "Reading List">
+<!ENTITY readingList.sidebar.commandKey           "R">
 <!ENTITY emeLearnMoreContextMenu.label            "Learn more about DRM…">
 <!ENTITY emeLearnMoreContextMenu.accesskey        "D">
--- a/browser/themes/linux/jar.mn
+++ b/browser/themes/linux/jar.mn
@@ -86,16 +86,17 @@ browser.jar:
   skin/classic/browser/Toolbar-inverted.png
   skin/classic/browser/Toolbar-small.png
   skin/classic/browser/undoCloseTab.png                        (../shared/undoCloseTab.png)
   skin/classic/browser/urlbar-arrow.png
   skin/classic/browser/session-restore.svg                  (../shared/incontent-icons/session-restore.svg)
   skin/classic/browser/tab-crashed.svg                      (../shared/incontent-icons/tab-crashed.svg)
   skin/classic/browser/welcome-back.svg                     (../shared/incontent-icons/welcome-back.svg)
   skin/classic/browser/reader-mode-16.png             (../shared/reader/reader-mode-16.png)
+  skin/classic/browser/readinglist/sidebar.css        (../shared/readinglist/sidebar.css)
   skin/classic/browser/webRTC-shareDevice-16.png
   skin/classic/browser/webRTC-shareDevice-64.png
   skin/classic/browser/webRTC-sharingDevice-16.png    (../shared/webrtc/webRTC-sharingDevice-16.png)
   skin/classic/browser/webRTC-shareMicrophone-16.png
   skin/classic/browser/webRTC-shareMicrophone-64.png
   skin/classic/browser/webRTC-sharingMicrophone-16.png (../shared/webrtc/webRTC-sharingMicrophone-16.png)
   skin/classic/browser/webRTC-shareScreen-16.png      (../shared/webrtc/webRTC-shareScreen-16.png)
   skin/classic/browser/webRTC-shareScreen-64.png      (../shared/webrtc/webRTC-shareScreen-64.png)
--- a/browser/themes/osx/jar.mn
+++ b/browser/themes/osx/jar.mn
@@ -137,16 +137,17 @@ browser.jar:
   skin/classic/browser/urlbar-arrow@2x.png
   skin/classic/browser/urlbar-popup-blocked.png
   skin/classic/browser/urlbar-popup-blocked@2x.png
   skin/classic/browser/session-restore.svg            (../shared/incontent-icons/session-restore.svg)
   skin/classic/browser/tab-crashed.svg                (../shared/incontent-icons/tab-crashed.svg)
   skin/classic/browser/welcome-back.svg               (../shared/incontent-icons/welcome-back.svg)
   skin/classic/browser/reader-mode-16.png             (../shared/reader/reader-mode-16.png)
   skin/classic/browser/reader-mode-16@2x.png          (../shared/reader/reader-mode-16@2x.png)
+  skin/classic/browser/readinglist/sidebar.css        (../shared/readinglist/sidebar.css)
   skin/classic/browser/webRTC-shareDevice-16.png
   skin/classic/browser/webRTC-shareDevice-16@2x.png
   skin/classic/browser/webRTC-shareDevice-64.png
   skin/classic/browser/webRTC-shareDevice-64@2x.png
   skin/classic/browser/webRTC-sharingDevice-16.png    (../shared/webrtc/webRTC-sharingDevice-16.png)
   skin/classic/browser/webRTC-sharingDevice-16@2x.png (../shared/webrtc/webRTC-sharingDevice-16@2x.png)
   skin/classic/browser/webRTC-shareMicrophone-16.png
   skin/classic/browser/webRTC-shareMicrophone-16@2x.png
new file mode 100644
--- /dev/null
+++ b/browser/themes/shared/readinglist/sidebar.css
@@ -0,0 +1,72 @@
+/* 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/. */
+
+:root, body {
+  height: 100%;
+  overflow-x: hidden;
+}
+
+body {
+  margin: 0;
+  font: message-box;
+  background: #F8F7F8;
+  color: #333333;
+  -moz-user-select: none;
+  overflow: hidden;
+}
+
+#list {
+  height: 100%;
+  overflow-x: auto;
+}
+
+.item {
+  display: flex;
+  flex-flow: row;
+  cursor: pointer;
+  padding: 6px;
+}
+
+.item.active {
+  background: #FEFEFE;
+}
+
+.item.selected {
+  background: #FDFDFD;
+}
+
+.item-thumb-container {
+  min-width: 64px;
+  max-width: 64px;
+  min-height: 40px;
+  max-height: 40px;
+  background: #EBEBEB;
+  border: 1px solid white;
+  box-shadow: 0px 1px 2px rgba(0,0,0,.35);
+  margin: 5px;
+}
+
+.item-summary-container {
+  display: flex;
+  flex-flow: column;
+  -moz-padding-start: 4px;
+  overflow: hidden;
+}
+
+.item-title {
+  overflow: hidden;
+  height: 2.8em;
+}
+
+.item-domain {
+  overflow: hidden;
+  text-overflow: ellipsis;
+  white-space: nowrap;
+  max-height: 1.4em;
+  color: #0095DD;
+}
+
+.item:hover .item-domain {
+  color: #008ACB;
+}
--- a/browser/themes/windows/jar.mn
+++ b/browser/themes/windows/jar.mn
@@ -105,16 +105,17 @@ browser.jar:
         skin/classic/browser/undoCloseTab@2x.png                     (../shared/undoCloseTab@2x.png)
         skin/classic/browser/urlbar-arrow.png
         skin/classic/browser/urlbar-popup-blocked.png
         skin/classic/browser/urlbar-history-dropmarker.png
         skin/classic/browser/session-restore.svg                     (../shared/incontent-icons/session-restore.svg)
         skin/classic/browser/tab-crashed.svg                         (../shared/incontent-icons/tab-crashed.svg)
         skin/classic/browser/welcome-back.svg                        (../shared/incontent-icons/welcome-back.svg)
         skin/classic/browser/reader-mode-16.png                      (../shared/reader/reader-mode-16.png)
+        skin/classic/browser/readinglist/sidebar.css                 (../shared/readinglist/sidebar.css)
         skin/classic/browser/notification-pluginNormal.png           (../shared/plugins/notification-pluginNormal.png)
         skin/classic/browser/notification-pluginAlert.png            (../shared/plugins/notification-pluginAlert.png)
         skin/classic/browser/notification-pluginBlocked.png          (../shared/plugins/notification-pluginBlocked.png)
         skin/classic/browser/webRTC-shareDevice-16.png
         skin/classic/browser/webRTC-shareDevice-64.png
         skin/classic/browser/webRTC-sharingDevice-16.png             (../shared/webrtc/webRTC-sharingDevice-16.png)
         skin/classic/browser/webRTC-shareMicrophone-16.png
         skin/classic/browser/webRTC-shareMicrophone-64.png
@@ -570,16 +571,17 @@ browser.jar:
         skin/classic/aero/browser/undoCloseTab@2x.png                     (../shared/undoCloseTab@2x.png)
         skin/classic/aero/browser/urlbar-arrow.png
         skin/classic/aero/browser/urlbar-popup-blocked.png
         skin/classic/aero/browser/urlbar-history-dropmarker.png
         skin/classic/aero/browser/session-restore.svg               (../shared/incontent-icons/session-restore.svg)
         skin/classic/aero/browser/tab-crashed.svg                   (../shared/incontent-icons/tab-crashed.svg)
         skin/classic/aero/browser/welcome-back.svg                  (../shared/incontent-icons/welcome-back.svg)
         skin/classic/aero/browser/reader-mode-16.png                (../shared/reader/reader-mode-16.png)
+        skin/classic/aero/browser/readinglist/sidebar.css           (../shared/readinglist/sidebar.css)
         skin/classic/aero/browser/notification-pluginNormal.png     (../shared/plugins/notification-pluginNormal.png)
         skin/classic/aero/browser/notification-pluginAlert.png      (../shared/plugins/notification-pluginAlert.png)
         skin/classic/aero/browser/notification-pluginBlocked.png    (../shared/plugins/notification-pluginBlocked.png)
         skin/classic/aero/browser/webRTC-shareDevice-16.png
         skin/classic/aero/browser/webRTC-shareDevice-64.png
         skin/classic/aero/browser/webRTC-sharingDevice-16.png             (../shared/webrtc/webRTC-sharingDevice-16.png)
         skin/classic/aero/browser/webRTC-shareMicrophone-16.png
         skin/classic/aero/browser/webRTC-shareMicrophone-64.png