Bug 1123517 - [ReadingList] Implement basic sidebar that lists unread ReadingList items. r=florian

This commit is contained in:
Blair McBride 2015-02-19 00:33:49 +13:00
parent 89f2f315e7
commit 204a0f5703
26 changed files with 1407 additions and 4 deletions

View File

@ -1856,3 +1856,6 @@ pref("dom.ipc.reportProcessHangs", true);
// Disable reader mode by default.
pref("reader.parse-on-load.enabled", false);
// Disable ReadingList by default.
pref("browser.readinglist.enabled", false);

View File

@ -223,6 +223,11 @@
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"/>

View File

@ -0,0 +1,61 @@
/*
# 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.updateUI();
},
/**
* 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;
document.getElementById("readingListSidebar").setAttribute("hidden", !enabled);
if (!enabled) {
this.hideSidebar();
}
},
/**
* 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();
}
},
};

View File

@ -148,6 +148,11 @@
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');"/>
@ -418,6 +423,11 @@
#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"/>

View File

@ -13,6 +13,8 @@ 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",
@ -225,6 +227,7 @@ let gInitialPages = [
#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
@ -1371,6 +1374,7 @@ var gBrowserInit = {
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.

View File

@ -1130,7 +1130,7 @@
<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"/>

View File

@ -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);
});
},
};

View File

@ -6,6 +6,10 @@
SPHINX_TREES['sslerrorreport'] = 'content/docs/sslerrorreport'
TESTING_JS_MODULES += [
'content/test/BrowserUITestUtils.jsm',
]
MOCHITEST_MANIFESTS += [
'content/test/general/mochitest.ini',
]

View File

@ -53,9 +53,13 @@ function removeWidget() {
// 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;
}

View File

@ -15,6 +15,7 @@ DIRS += [
'places',
'preferences',
'privatebrowsing',
'readinglist',
'search',
'sessionstore',
'shell',

View File

@ -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();

View File

@ -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

View File

@ -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']

View File

@ -0,0 +1,392 @@
/* 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");
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);
},
/**
* 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());

View File

@ -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>

View File

@ -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();
});
},
};

View File

@ -0,0 +1,7 @@
[DEFAULT]
support-files =
head.js
[browser_ui_enable_disable.js]
[browser_sidebar_list.js]
[browser_sidebar_mouse_nav.js]

View File

@ -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);
});

View File

@ -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);
});

View File

@ -0,0 +1,47 @@
/**
* 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");
}
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;
checkRLState();
});

View File

@ -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);
});

View File

@ -838,5 +838,7 @@ just addresses the organization to follow, e.g. "This site is run by " -->
<!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">

View File

@ -91,6 +91,7 @@ browser.jar:
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)

View File

@ -142,6 +142,7 @@ browser.jar:
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

View File

@ -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;
}

View File

@ -110,6 +110,7 @@ browser.jar:
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)
@ -576,6 +577,7 @@ browser.jar:
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)