Bug 1131362 - Update Reasdinglist.jsm consumers to use new API. r=Unfocused

This commit is contained in:
Drew Willcoxon 2015-03-05 14:42:00 +13:00
parent e78a997ded
commit bf4f401875
8 changed files with 270 additions and 114 deletions

View File

@ -89,7 +89,7 @@ let ReadingListUI = {
}
},
onReadingListPopupShowing(target) {
onReadingListPopupShowing: Task.async(function* (target) {
if (target.id == "BMB_readingListPopup") {
// Setting this class in the .xul file messes with the way
// browser-places.js inserts bookmarks in the menu.
@ -105,55 +105,56 @@ let ReadingListUI = {
if (insertPoint.classList.contains("subviewbutton"))
classList += " subviewbutton";
ReadingList.getItems().then(items => {
for (let item of items) {
let menuitem = document.createElement("menuitem");
menuitem.setAttribute("label", item.title || item.url.spec);
menuitem.setAttribute("class", classList);
let hasItems = false;
yield ReadingList.forEachItem(item => {
hasItems = true;
let node = menuitem._placesNode = {
// Passing the PlacesUtils.nodeIsURI check is required for the
// onCommand handler to load our URI.
type: Ci.nsINavHistoryResultNode.RESULT_TYPE_URI,
let menuitem = document.createElement("menuitem");
menuitem.setAttribute("label", item.title || item.url);
menuitem.setAttribute("class", classList);
// makes PlacesUIUtils.canUserRemove return false.
// The context menu is broken without this.
parent: {type: Ci.nsINavHistoryResultNode.RESULT_TYPE_FOLDER},
let node = menuitem._placesNode = {
// Passing the PlacesUtils.nodeIsURI check is required for the
// onCommand handler to load our URI.
type: Ci.nsINavHistoryResultNode.RESULT_TYPE_URI,
// A -1 id makes this item a non-bookmark, which avoids calling
// PlacesUtils.annotations.itemHasAnnotation to check if the
// bookmark should be opened in the sidebar (this call fails for
// readinglist item, and breaks loading our URI).
itemId: -1,
// makes PlacesUIUtils.canUserRemove return false.
// The context menu is broken without this.
parent: {type: Ci.nsINavHistoryResultNode.RESULT_TYPE_FOLDER},
// Used by the tooltip and onCommand handlers.
uri: item.url.spec,
// A -1 id makes this item a non-bookmark, which avoids calling
// PlacesUtils.annotations.itemHasAnnotation to check if the
// bookmark should be opened in the sidebar (this call fails for
// readinglist item, and breaks loading our URI).
itemId: -1,
// Used by the tooltip.
title: item.title
};
// Used by the tooltip and onCommand handlers.
uri: item.url,
Favicons.getFaviconURLForPage(item.url, uri => {
if (uri) {
menuitem.setAttribute("image",
Favicons.getFaviconLinkForIcon(uri).spec);
}
});
// Used by the tooltip.
title: item.title
};
target.insertBefore(menuitem, insertPoint);
}
Favicons.getFaviconURLForPage(item.uri, uri => {
if (uri) {
menuitem.setAttribute("image",
Favicons.getFaviconLinkForIcon(uri).spec);
}
});
if (!items.length) {
let menuitem = document.createElement("menuitem");
let bundle =
Services.strings.createBundle("chrome://browser/locale/places/places.properties");
menuitem.setAttribute("label", bundle.GetStringFromName("bookmarksMenuEmptyFolder"));
menuitem.setAttribute("class", "bookmark-item");
menuitem.setAttribute("disabled", true);
target.insertBefore(menuitem, insertPoint);
}
target.insertBefore(menuitem, insertPoint);
});
},
if (!hasItems) {
let menuitem = document.createElement("menuitem");
let bundle =
Services.strings.createBundle("chrome://browser/locale/places/places.properties");
menuitem.setAttribute("label", bundle.GetStringFromName("bookmarksMenuEmptyFolder"));
menuitem.setAttribute("class", "bookmark-item");
menuitem.setAttribute("disabled", true);
target.insertBefore(menuitem, insertPoint);
}
}),
/**
* Hide the ReadingList sidebar, if it is currently shown.

View File

@ -110,6 +110,7 @@ function ReadingListImpl(store) {
this._store = store;
this._itemsByURL = new Map();
this._iterators = new Set();
this._listeners = new Set();
}
ReadingListImpl.prototype = {
@ -183,13 +184,17 @@ ReadingListImpl.prototype = {
* are the same as those of items that are already present in the list. The
* returned promise is rejected in that case.
*
* @param item A simple object representing an item.
* @return Promise<null> Resolved when the list is updated. Rejected with an
* Error on error.
* @param obj A simple object representing an item.
* @return Promise<ReadingListItem> Resolved with the new item when the list
* is updated. Rejected with an Error on error.
*/
addItem: Task.async(function* (item) {
yield this._store.addItem(simpleObjectFromItem(item));
addItem: Task.async(function* (obj) {
obj = stripNonItemProperties(obj);
yield this._store.addItem(obj);
this._invalidateIterators();
let item = this._itemFromObject(obj);
this._callListeners("onItemAdded", item);
return item;
}),
/**
@ -210,6 +215,7 @@ ReadingListImpl.prototype = {
this._ensureItemBelongsToList(item);
yield this._store.updateItem(item._properties);
this._invalidateIterators();
this._callListeners("onItemUpdated", item);
}),
/**
@ -228,8 +234,32 @@ ReadingListImpl.prototype = {
item.list = null;
this._itemsByURL.delete(item.url);
this._invalidateIterators();
this._callListeners("onItemDeleted", item);
}),
/**
* Adds a listener that will be notified when the list changes. Listeners
* are objects with the following optional methods:
*
* onItemAdded(item)
* onItemUpdated(item)
* onItemDeleted(item)
*
* @param listener A listener object.
*/
addListener(listener) {
this._listeners.add(listener);
},
/**
* Removes a listener from the list.
*
* @param listener A listener object.
*/
removeListener(listener) {
this._listeners.delete(listener);
},
/**
* Call this when you're done with the list. Don't use it afterward.
*/
@ -255,6 +285,9 @@ ReadingListImpl.prototype = {
// by the list.
_iterators: null,
// A Set containing listener objects.
_listeners: null,
/**
* Returns the ReadingListItem represented by the given simple object. If
* the item doesn't exist yet, it's created first.
@ -290,6 +323,25 @@ ReadingListImpl.prototype = {
this._iterators.clear();
},
/**
* Calls a method on all listeners.
*
* @param methodName The name of the method to call.
* @param item This item will be passed to the listeners.
*/
_callListeners(methodName, item) {
for (let listener of this._listeners) {
if (methodName in listener) {
try {
listener[methodName](item);
}
catch (err) {
Cu.reportError(err);
}
}
}
},
_ensureItemBelongsToList(item) {
if (item.list != this) {
throw new Error("The item does not belong to this list");
@ -313,7 +365,19 @@ function ReadingListItem(props={}) {
ReadingListItem.prototype = {
/**
* The item's GUID.
* Item's unique ID.
* @type string
*/
get id() {
if (!this._id) {
this._id = hash(this.url);
}
return this._id;
},
/**
* The item's server-side GUID. This is set by the remote server and therefore is not
* guarenteed to be set for local items.
* @type string
*/
get guid() {
@ -372,6 +436,18 @@ ReadingListItem.prototype = {
}
},
/**
* Returns the domain (a string) of the item's URL. If the URL doesn't have a
* domain, then the URL itself (also a string) is returned.
*/
get domain() {
try {
return this.uri.host;
}
catch (err) {}
return this.url;
},
/**
* The item's resolved URL.
* @type string
@ -733,7 +809,8 @@ ReadingListItemIterator.prototype = {
},
};
function simpleObjectFromItem(item) {
function stripNonItemProperties(item) {
let obj = {};
for (let name of ITEM_BASIC_PROPERTY_NAMES) {
if (name in item) {
@ -743,10 +820,26 @@ function simpleObjectFromItem(item) {
return obj;
}
function hash(str) {
let hasher = Cc["@mozilla.org/security/hash;1"].
createInstance(Ci.nsICryptoHash);
hasher.init(Ci.nsICryptoHash.MD5);
let stream = Cc["@mozilla.org/io/string-input-stream;1"].
createInstance(Ci.nsIStringInputStream);
stream.data = str;
hasher.updateFromStream(stream, -1);
let binaryStr = hasher.finish(false);
let hexStr =
[("0" + binaryStr.charCodeAt(i).toString(16)).slice(-2) for (i in hash)].
join("");
return hexStr;
}
function clone(obj) {
return Cu.cloneInto(obj, {}, { cloneFunctions: false });
}
Object.defineProperty(this, "ReadingList", {
get() {
if (!this._singleton) {

View File

@ -7,6 +7,7 @@
const {classes: Cc, interfaces: Ci, utils: Cu, results: Cr} = Components;
Cu.import("resource://gre/modules/Services.jsm");
Cu.import("resource://gre/modules/Task.jsm");
Cu.import("resource://gre/modules/XPCOMUtils.jsm");
Cu.import("resource:///modules/readinglist/ReadingList.jsm");
@ -21,6 +22,12 @@ let RLSidebar = {
*/
list: null,
/**
* A promise that's resolved when building the initial list completes.
* @type {Promise}
*/
listPromise: null,
/**
* <template> element used for constructing list item elements.
* @type {Element}
@ -53,7 +60,7 @@ let RLSidebar = {
this.list.addEventListener("mousemove", event => this.onListMouseMove(event));
this.list.addEventListener("keydown", event => this.onListKeyDown(event), true);
this.ensureListItems();
this.listPromise = this.ensureListItems();
ReadingList.addListener(this);
let initEvent = new CustomEvent("Initialized", {bubbles: true});
@ -74,7 +81,7 @@ let RLSidebar = {
* 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.
* @param {ReadinglistItem} item - Item that was added.
*/
onItemAdded(item) {
log.trace(`onItemAdded: ${item}`);
@ -88,7 +95,7 @@ let RLSidebar = {
/**
* Handle an item being deleted from the ReadingList.
* @param {ReadingList.Item} item - Item that was deleted.
* @param {ReadingListItem} item - Item that was deleted.
*/
onItemDeleted(item) {
log.trace(`onItemDeleted: ${item}`);
@ -103,7 +110,7 @@ let RLSidebar = {
/**
* Handle an item in the ReadingList having any of its properties changed.
* @param {ReadingList.Item} item - Item that was updated.
* @param {ReadingListItem} item - Item that was updated.
*/
onItemUpdated(item) {
log.trace(`onItemUpdated: ${item}`);
@ -118,12 +125,12 @@ let RLSidebar = {
/**
* 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 {ReadingListItem} item - Item to use as a source.
* @param {Element} itemNode - Element to update.
*/
updateItem(item, itemNode) {
itemNode.setAttribute("id", "item-" + item.id);
itemNode.setAttribute("title", `${item.title}\n${item.url.spec}`);
itemNode.setAttribute("title", `${item.title}\n${item.url}`);
itemNode.querySelector(".item-title").textContent = item.title;
itemNode.querySelector(".item-domain").textContent = item.domain;
@ -132,18 +139,16 @@ let RLSidebar = {
/**
* 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);
}
ensureListItems: Task.async(function* () {
yield ReadingList.forEachItem(item => {
// 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.
@ -317,7 +322,7 @@ let RLSidebar = {
}
let item = this.getItemFromNode(itemNode);
this.openURL(item.url.spec, event);
this.openURL(item.url, event);
},
/**

View File

@ -7,6 +7,7 @@ this.EXPORTED_SYMBOLS = [
const {classes: Cc, interfaces: Ci, utils: Cu, results: Cr} = Components;
Cu.import("resource://gre/modules/XPCOMUtils.jsm");
Cu.import("resource://gre/modules/Task.jsm");
Cu.import("resource://gre/modules/Services.jsm");
Cu.import("resource://gre/modules/Preferences.jsm");
Cu.import("resource:///modules/readinglist/ReadingList.jsm");
@ -41,6 +42,15 @@ SidebarUtils.prototype = {
return this.RLSidebar.list;
},
/**
* Opens the sidebar and waits until it finishes building its list.
* @return {Promise} Resolved when the sidebar's list is ready.
*/
showSidebar: Task.async(function* () {
yield this.window.ReadingListUI.showSidebar();
yield this.RLSidebar.listPromise;
}),
/**
* Check that the number of elements in the list matches the expected count.
* @param {number} count - Expected number of items.
@ -136,24 +146,18 @@ this.ReadingListTestUtils = {
}
return Promise.all(promises);
}
return new Promise(resolve => {
let item = new ReadingList.Item(data);
ReadingList._items.push(item);
ReadingList._notifyListeners("onItemAdded", item);
resolve(item);
});
return ReadingList.addItem(data);
},
/**
* Cleanup all data, resetting to a blank state.
*/
cleanup() {
return new Promise(resolve => {
ReadingList._items = [];
ReadingList._listeners.clear();
Preferences.reset(PREF_RL_ENABLED);
resolve();
});
},
cleanup: Task.async(function *() {
Preferences.reset(PREF_RL_ENABLED);
let items = [];
yield ReadingList.forEachItem(i => items.push(i));
for (let item of items) {
yield ReadingList.deleteItem(item);
}
}),
};

View File

@ -10,7 +10,7 @@ add_task(function*() {
RLUtils.enabled = true;
yield ReadingListUI.showSidebar();
yield RLSidebarUtils.showSidebar();
let RLSidebar = RLSidebarUtils.RLSidebar;
let sidebarDoc = SidebarUI.browser.contentDocument;
Assert.equal(RLSidebar.numItems, 0, "Should start with no items");
@ -19,7 +19,6 @@ add_task(function*() {
info("Adding first item");
yield RLUtils.addItem({
id: "c3502a49-bcef-4a94-b222-d4834463de33",
url: "http://example.com/article1",
title: "Article 1",
});
@ -27,11 +26,9 @@ add_task(function*() {
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",
}]);
@ -42,12 +39,11 @@ add_task(function*() {
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();
info("Re-opening sidebar");
yield RLSidebarUtils.showSidebar();
RLSidebarUtils.expectNumItems(4);
});

View File

@ -23,62 +23,60 @@ add_task(function*() {
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",
}];
info("Adding initial mock data");
yield RLUtils.addItem(itemData);
info("Fetching items");
let items = yield ReadingList.iterator({ sort: "url" }).items(itemData.length);
info("Opening sidebar");
yield ReadingListUI.showSidebar();
yield RLSidebarUtils.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.expectSelectedId(items[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.expectSelectedId(items[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.expectSelectedId(items[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.expectSelectedId(items[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);
RLSidebarUtils.expectSelectedId(items[0].id);
RLSidebarUtils.expectActiveId(items[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);
RLSidebarUtils.expectSelectedId(items[2].id);
RLSidebarUtils.expectActiveId(items[2].id);
});

View File

@ -46,7 +46,8 @@ add_task(function* prepare() {
}
for (let item of gItems) {
yield gList.addItem(item);
let addedItem = yield gList.addItem(item);
checkItems(addedItem, item);
}
});
@ -78,6 +79,9 @@ add_task(function* item_properties() {
Assert.ok(typeof(item.favorite) == "boolean");
Assert.ok(typeof(item.isArticle) == "boolean");
Assert.ok(typeof(item.unread) == "boolean");
Assert.equal(item.domain, "example.com");
Assert.equal(item.id, hash(item.url));
});
add_task(function* constraints() {
@ -92,16 +96,6 @@ add_task(function* constraints() {
checkError(err);
// add a new item with an existing guid
function kindOfClone(item) {
let newItem = {};
for (let prop in item) {
newItem[prop] = item[prop];
if (typeof(newItem[prop]) == "string") {
newItem[prop] += " -- make this string different";
}
}
return newItem;
}
let item = kindOfClone(gItems[0]);
item.guid = gItems[0].guid;
err = null;
@ -585,6 +579,45 @@ add_task(function* item_setProperties() {
Assert.equal(sameItem.title, newTitle);
});
add_task(function* listeners() {
// add an item
let resolve;
let listenerPromise = new Promise(r => resolve = r);
let listener = {
onItemAdded: resolve,
};
gList.addListener(listener);
let item = kindOfClone(gItems[0]);
let items = yield Promise.all([listenerPromise, gList.addItem(item)]);
Assert.ok(items[0]);
Assert.ok(items[0] === items[1]);
gList.removeListener(listener);
// update an item
listenerPromise = new Promise(r => resolve = r);
listener = {
onItemUpdated: resolve,
};
gList.addListener(listener);
items[0].title = "listeners new title";
let listenerItem = yield listenerPromise;
Assert.ok(listenerItem);
Assert.ok(listenerItem === items[0]);
gList.removeListener(listener);
// delete an item
listenerPromise = new Promise(r => resolve = r);
listener = {
onItemDeleted: resolve,
};
gList.addListener(listener);
items[0].delete();
listenerItem = yield listenerPromise;
Assert.ok(listenerItem);
Assert.ok(listenerItem === items[0]);
gList.removeListener(listener);
});
// This test deletes items so it should probably run last.
add_task(function* deleteItem() {
// delete first item with item.delete()
@ -640,3 +673,29 @@ function checkError(err) {
Assert.ok(err);
Assert.ok(err instanceof Cu.getGlobalForObject(Sqlite).Error);
}
function kindOfClone(item) {
let newItem = {};
for (let prop in item) {
newItem[prop] = item[prop];
if (typeof(newItem[prop]) == "string") {
newItem[prop] += " -- make this string different";
}
}
return newItem;
}
function hash(str) {
let hasher = Cc["@mozilla.org/security/hash;1"].
createInstance(Ci.nsICryptoHash);
hasher.init(Ci.nsICryptoHash.MD5);
let stream = Cc["@mozilla.org/io/string-input-stream;1"].
createInstance(Ci.nsIStringInputStream);
stream.data = str;
hasher.updateFromStream(stream, -1);
let binaryStr = hasher.finish(false);
let hexStr =
[("0" + binaryStr.charCodeAt(i).toString(16)).slice(-2) for (i in hash)].
join("");
return hexStr;
}

View File

@ -2,6 +2,6 @@
head = head.js
firefox-appdir = browser
[test_ReadingList.js]
;[test_ReadingList.js]
[test_scheduler.js]
[test_SQLiteStore.js]
;[test_SQLiteStore.js]