зеркало из https://github.com/mozilla/gecko-dev.git
Merge mozilla-central to b2g-inbound
This commit is contained in:
Коммит
3531b883ec
2
CLOBBER
2
CLOBBER
|
@ -22,4 +22,4 @@
|
|||
# changes to stick? As of bug 928195, this shouldn't be necessary! Please
|
||||
# don't change CLOBBER for WebIDL changes any more.
|
||||
|
||||
Bug 1190180 - need clobber for backouts
|
||||
Bug 1186748 needed a CLOBBER again
|
||||
|
|
|
@ -692,7 +692,8 @@ void
|
|||
DocAccessible::AttributeWillChange(nsIDocument* aDocument,
|
||||
dom::Element* aElement,
|
||||
int32_t aNameSpaceID,
|
||||
nsIAtom* aAttribute, int32_t aModType)
|
||||
nsIAtom* aAttribute, int32_t aModType,
|
||||
const nsAttrValue* aNewValue)
|
||||
{
|
||||
Accessible* accessible = GetAccessible(aElement);
|
||||
if (!accessible) {
|
||||
|
@ -733,7 +734,8 @@ void
|
|||
DocAccessible::AttributeChanged(nsIDocument* aDocument,
|
||||
dom::Element* aElement,
|
||||
int32_t aNameSpaceID, nsIAtom* aAttribute,
|
||||
int32_t aModType)
|
||||
int32_t aModType,
|
||||
const nsAttrValue* aOldValue)
|
||||
{
|
||||
NS_ASSERTION(!IsDefunct(),
|
||||
"Attribute changed called on defunct document accessible!");
|
||||
|
|
|
@ -11,7 +11,6 @@ const BUILTIN_SIDEBAR_MENUITEMS = exports.BUILTIN_SIDEBAR_MENUITEMS = [
|
|||
'menu_socialSidebar',
|
||||
'menu_historySidebar',
|
||||
'menu_bookmarksSidebar',
|
||||
'menu_readingListSidebar'
|
||||
];
|
||||
|
||||
function isSidebarShowing(window) {
|
||||
|
|
|
@ -17,7 +17,6 @@ const BUILTIN_SIDEBAR_MENUITEMS = exports.BUILTIN_SIDEBAR_MENUITEMS = [
|
|||
'menu_socialSidebar',
|
||||
'menu_historySidebar',
|
||||
'menu_bookmarksSidebar',
|
||||
'menu_readingListSidebar'
|
||||
];
|
||||
|
||||
function isSidebarShowing(window) {
|
||||
|
|
|
@ -1927,11 +1927,6 @@ pref("dom.ipc.reportProcessHangs", false);
|
|||
pref("dom.ipc.reportProcessHangs", true);
|
||||
#endif
|
||||
|
||||
pref("browser.readinglist.enabled", false);
|
||||
pref("browser.readinglist.sidebarEverOpened", false);
|
||||
pref("readinglist.scheduler.enabled", false);
|
||||
pref("readinglist.server", "https://readinglist.services.mozilla.com/v1");
|
||||
|
||||
pref("browser.reader.detectedFirstArticle", false);
|
||||
// Don't limit how many nodes we care about on desktop:
|
||||
pref("reader.parse-node-limit", 0);
|
||||
|
|
|
@ -49,6 +49,7 @@ const gXPInstallObserver = {
|
|||
var options = {
|
||||
displayURI: installInfo.originatingURI,
|
||||
timeout: Date.now() + 30000,
|
||||
removeOnDismissal: true,
|
||||
};
|
||||
|
||||
let cancelInstallation = () => {
|
||||
|
|
|
@ -210,10 +210,6 @@
|
|||
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"/>
|
||||
|
@ -443,30 +439,6 @@
|
|||
onpopupshowing="if (!this.parentNode._placesView)
|
||||
new PlacesMenu(event, 'place:folder=TOOLBAR');"/>
|
||||
</menu>
|
||||
#ifndef XP_MACOSX
|
||||
# Disabled on Mac because we can't fill native menupopups asynchronously
|
||||
<menuseparator id="menu_readingListSeparator">
|
||||
<observes element="readingListSidebar" attribute="hidden"/>
|
||||
</menuseparator>
|
||||
<menu id="menu_readingList"
|
||||
class="menu-iconic bookmark-item"
|
||||
label="&readingList.label;"
|
||||
container="true">
|
||||
<observes element="readingListSidebar" attribute="hidden"/>
|
||||
<menupopup id="readingListPopup"
|
||||
#ifndef XP_MACOSX
|
||||
placespopup="true"
|
||||
#endif
|
||||
onpopupshowing="ReadingListUI.onReadingListPopupShowing(this);">
|
||||
<menuseparator id="viewReadingListSidebarSeparator"/>
|
||||
<menuitem id="viewReadingListSidebar" class="subviewbutton"
|
||||
oncommand="SidebarUI.toggle('readingListSidebar');"
|
||||
label="&readingList.showSidebar.label;">
|
||||
<observes element="readingListSidebar" attribute="checked"/>
|
||||
</menuitem>
|
||||
</menupopup>
|
||||
</menu>
|
||||
#endif
|
||||
<menuseparator id="bookmarksMenuItemsSeparator"/>
|
||||
<!-- Bookmarks menu items -->
|
||||
<menuseparator builder="end"
|
||||
|
|
|
@ -1,376 +0,0 @@
|
|||
/*
|
||||
# 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/.
|
||||
*/
|
||||
|
||||
XPCOMUtils.defineLazyModuleGetter(this, "ReadingList",
|
||||
"resource:///modules/readinglist/ReadingList.jsm");
|
||||
|
||||
const READINGLIST_COMMAND_ID = "readingListSidebar";
|
||||
|
||||
let ReadingListUI = {
|
||||
/**
|
||||
* Frame-script messages we want to listen to.
|
||||
* @type {[string]}
|
||||
*/
|
||||
MESSAGES: [
|
||||
"ReadingList:GetVisibility",
|
||||
"ReadingList:ToggleVisibility",
|
||||
"ReadingList:ShowIntro",
|
||||
],
|
||||
|
||||
/**
|
||||
* Add-to-ReadingList toolbar button in the URLbar.
|
||||
* @type {Element}
|
||||
*/
|
||||
toolbarButton: null,
|
||||
|
||||
/**
|
||||
* Whether this object is currently registered as a listener with ReadingList.
|
||||
* Used to avoid inadvertantly loading the ReadLingList.jsm module on startup.
|
||||
* @type {Boolean}
|
||||
*/
|
||||
listenerRegistered: false,
|
||||
|
||||
/**
|
||||
* Initialize the ReadingList UI.
|
||||
*/
|
||||
init() {
|
||||
this.toolbarButton = document.getElementById("readinglist-addremove-button");
|
||||
|
||||
Preferences.observe("browser.readinglist.enabled", this.updateUI, this);
|
||||
|
||||
const mm = window.messageManager;
|
||||
for (let msg of this.MESSAGES) {
|
||||
mm.addMessageListener(msg, this);
|
||||
}
|
||||
|
||||
this.updateUI();
|
||||
},
|
||||
|
||||
/**
|
||||
* Un-initialize the ReadingList UI.
|
||||
*/
|
||||
uninit() {
|
||||
Preferences.ignore("browser.readinglist.enabled", this.updateUI, this);
|
||||
|
||||
const mm = window.messageManager;
|
||||
for (let msg of this.MESSAGES) {
|
||||
mm.removeMessageListener(msg, this);
|
||||
}
|
||||
|
||||
if (this.listenerRegistered) {
|
||||
ReadingList.removeListener(this);
|
||||
this.listenerRegistered = false;
|
||||
}
|
||||
},
|
||||
|
||||
/**
|
||||
* 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 == READINGLIST_COMMAND_ID;
|
||||
},
|
||||
|
||||
/**
|
||||
* 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 is a no-op if we're already registered.
|
||||
ReadingList.addListener(this);
|
||||
this.listenerRegistered = true;
|
||||
} else {
|
||||
if (this.listenerRegistered) {
|
||||
// This is safe to call if we're not currently registered, but we don't
|
||||
// want to forcibly load the normally lazy-loaded module on startup.
|
||||
ReadingList.removeListener(this);
|
||||
this.listenerRegistered = false;
|
||||
}
|
||||
|
||||
this.hideSidebar();
|
||||
}
|
||||
|
||||
document.getElementById(READINGLIST_COMMAND_ID).setAttribute("hidden", !enabled);
|
||||
document.getElementById(READINGLIST_COMMAND_ID).setAttribute("disabled", !enabled);
|
||||
},
|
||||
|
||||
/**
|
||||
* Show the ReadingList sidebar.
|
||||
* @return {Promise}
|
||||
*/
|
||||
showSidebar() {
|
||||
if (this.enabled) {
|
||||
return SidebarUI.show(READINGLIST_COMMAND_ID);
|
||||
}
|
||||
},
|
||||
|
||||
/**
|
||||
* Hide the ReadingList sidebar, if it is currently shown.
|
||||
*/
|
||||
hideSidebar() {
|
||||
if (this.isSidebarOpen) {
|
||||
SidebarUI.hide();
|
||||
}
|
||||
},
|
||||
|
||||
/**
|
||||
* Re-refresh the ReadingList bookmarks submenu when it opens.
|
||||
*
|
||||
* @param {Element} target - Menu element opening.
|
||||
*/
|
||||
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.
|
||||
document.getElementById("BMB_viewReadingListSidebar")
|
||||
.classList.add("panel-subview-footer");
|
||||
}
|
||||
|
||||
while (!target.firstChild.id)
|
||||
target.firstChild.remove();
|
||||
|
||||
let classList = "menuitem-iconic bookmark-item menuitem-with-favicon";
|
||||
let insertPoint = target.firstChild;
|
||||
if (insertPoint.classList.contains("subviewbutton"))
|
||||
classList += " subviewbutton";
|
||||
|
||||
let hasItems = false;
|
||||
yield ReadingList.forEachItem(item => {
|
||||
hasItems = true;
|
||||
|
||||
let menuitem = document.createElement("menuitem");
|
||||
menuitem.setAttribute("label", item.title || item.url);
|
||||
menuitem.setAttribute("class", classList);
|
||||
|
||||
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,
|
||||
|
||||
// makes PlacesUIUtils.canUserRemove return false.
|
||||
// The context menu is broken without this.
|
||||
parent: {type: Ci.nsINavHistoryResultNode.RESULT_TYPE_FOLDER},
|
||||
|
||||
// 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 and onCommand handlers.
|
||||
uri: item.url,
|
||||
|
||||
// Used by the tooltip.
|
||||
title: item.title
|
||||
};
|
||||
|
||||
Favicons.getFaviconURLForPage(item.uri, uri => {
|
||||
if (uri) {
|
||||
menuitem.setAttribute("image",
|
||||
Favicons.getFaviconLinkForIcon(uri).spec);
|
||||
}
|
||||
});
|
||||
|
||||
target.insertBefore(menuitem, insertPoint);
|
||||
}, {sort: "addedOn", descending: true});
|
||||
|
||||
if (!hasItems) {
|
||||
let menuitem = document.createElement("menuitem");
|
||||
let bundle =
|
||||
Services.strings.createBundle("chrome://browser/locale/places/places.properties");
|
||||
menuitem.setAttribute("label", bundle.GetStringFromName("bookmarksMenuEmptyFolder"));
|
||||
menuitem.setAttribute("class", "bookmark-item");
|
||||
menuitem.setAttribute("disabled", true);
|
||||
target.insertBefore(menuitem, insertPoint);
|
||||
}
|
||||
}),
|
||||
|
||||
/**
|
||||
* Hide the ReadingList sidebar, if it is currently shown.
|
||||
*/
|
||||
toggleSidebar() {
|
||||
if (this.enabled) {
|
||||
SidebarUI.toggle(READINGLIST_COMMAND_ID);
|
||||
}
|
||||
},
|
||||
|
||||
/**
|
||||
* Respond to messages.
|
||||
*/
|
||||
receiveMessage(message) {
|
||||
switch (message.name) {
|
||||
case "ReadingList:GetVisibility": {
|
||||
if (message.target.messageManager) {
|
||||
message.target.messageManager.sendAsyncMessage("ReadingList:VisibilityStatus",
|
||||
{ isOpen: this.isSidebarOpen });
|
||||
}
|
||||
break;
|
||||
}
|
||||
|
||||
case "ReadingList:ToggleVisibility": {
|
||||
this.toggleSidebar();
|
||||
break;
|
||||
}
|
||||
|
||||
case "ReadingList:ShowIntro": {
|
||||
if (this.enabled && !Preferences.get("browser.readinglist.introShown", false)) {
|
||||
Preferences.set("browser.readinglist.introShown", true);
|
||||
this.showSidebar();
|
||||
}
|
||||
break;
|
||||
}
|
||||
}
|
||||
},
|
||||
|
||||
/**
|
||||
* Handles toolbar button styling based on page proxy state changes.
|
||||
*
|
||||
* @see SetPageProxyState()
|
||||
*
|
||||
* @param {string} state - New state. Either "valid" or "invalid".
|
||||
*/
|
||||
onPageProxyStateChanged: Task.async(function* (state) {
|
||||
if (!this.toolbarButton) {
|
||||
// nothing to do if we have no button.
|
||||
return;
|
||||
}
|
||||
|
||||
let uri;
|
||||
if (this.enabled && state == "valid") {
|
||||
uri = gBrowser.currentURI;
|
||||
if (uri.schemeIs("about"))
|
||||
uri = ReaderMode.getOriginalUrl(uri.spec);
|
||||
else if (!uri.schemeIs("http") && !uri.schemeIs("https"))
|
||||
uri = null;
|
||||
}
|
||||
|
||||
let msg = {topic: "UpdateActiveItem", url: null};
|
||||
if (!uri) {
|
||||
this.toolbarButton.setAttribute("hidden", true);
|
||||
if (this.isSidebarOpen)
|
||||
document.getElementById("sidebar").contentWindow.postMessage(msg, "*");
|
||||
return;
|
||||
}
|
||||
|
||||
let isInList = yield ReadingList.hasItemForURL(uri);
|
||||
|
||||
if (window.closed) {
|
||||
// Skip updating the UI if the window was closed since our hasItemForURL call.
|
||||
return;
|
||||
}
|
||||
|
||||
if (this.isSidebarOpen) {
|
||||
if (isInList)
|
||||
msg.url = typeof uri == "string" ? uri : uri.spec;
|
||||
document.getElementById("sidebar").contentWindow.postMessage(msg, "*");
|
||||
}
|
||||
this.setToolbarButtonState(isInList);
|
||||
}),
|
||||
|
||||
/**
|
||||
* Set the state of the ReadingList toolbar button in the urlbar.
|
||||
* If the current tab's page is in the ReadingList (active), sets the button
|
||||
* to allow removing the page. Otherwise, sets the button to allow adding the
|
||||
* page (not active).
|
||||
*
|
||||
* @param {boolean} active - True if the button should be active (page is
|
||||
* already in the list).
|
||||
*/
|
||||
setToolbarButtonState(active) {
|
||||
this.toolbarButton.setAttribute("already-added", active);
|
||||
|
||||
let type = (active ? "remove" : "add");
|
||||
let tooltip = gNavigatorBundle.getString(`readingList.urlbar.${type}`);
|
||||
this.toolbarButton.setAttribute("tooltiptext", tooltip);
|
||||
|
||||
this.toolbarButton.removeAttribute("hidden");
|
||||
},
|
||||
|
||||
buttonClick(event) {
|
||||
if (event.button != 0) {
|
||||
return;
|
||||
}
|
||||
this.togglePageByBrowser(gBrowser.selectedBrowser);
|
||||
},
|
||||
|
||||
/**
|
||||
* Toggle a page (from a browser) in the ReadingList, adding if it's not already added, or
|
||||
* removing otherwise.
|
||||
*
|
||||
* @param {<xul:browser>} browser - Browser with page to toggle.
|
||||
* @returns {Promise} Promise resolved when operation has completed.
|
||||
*/
|
||||
togglePageByBrowser: Task.async(function* (browser) {
|
||||
let uri = browser.currentURI;
|
||||
if (uri.spec.startsWith("about:reader?"))
|
||||
uri = ReaderMode.getOriginalUrl(uri.spec);
|
||||
if (!uri)
|
||||
return;
|
||||
|
||||
let item = yield ReadingList.itemForURL(uri);
|
||||
if (item) {
|
||||
yield item.delete();
|
||||
} else {
|
||||
yield ReadingList.addItemFromBrowser(browser, uri);
|
||||
}
|
||||
}),
|
||||
|
||||
/**
|
||||
* Checks if a given item matches the current tab in this window.
|
||||
*
|
||||
* @param {ReadingListItem} item - Item to check
|
||||
* @returns True if match, false otherwise.
|
||||
*/
|
||||
isItemForCurrentBrowser(item) {
|
||||
let currentURL = gBrowser.currentURI.spec;
|
||||
if (currentURL.startsWith("about:reader?"))
|
||||
currentURL = ReaderMode.getOriginalUrl(currentURL);
|
||||
|
||||
if (item.url == currentURL || item.resolvedURL == currentURL) {
|
||||
return true;
|
||||
}
|
||||
return false;
|
||||
},
|
||||
|
||||
/**
|
||||
* ReadingList event handler for when an item is added.
|
||||
*
|
||||
* @param {ReadingListItem} item - Item added.
|
||||
*/
|
||||
onItemAdded(item) {
|
||||
if (!Services.prefs.getBoolPref("browser.readinglist.sidebarEverOpened")) {
|
||||
SidebarUI.show("readingListSidebar");
|
||||
}
|
||||
if (this.isItemForCurrentBrowser(item)) {
|
||||
this.setToolbarButtonState(true);
|
||||
if (this.isSidebarOpen) {
|
||||
let msg = {topic: "UpdateActiveItem", url: item.url};
|
||||
document.getElementById("sidebar").contentWindow.postMessage(msg, "*");
|
||||
}
|
||||
}
|
||||
},
|
||||
|
||||
/**
|
||||
* ReadingList event handler for when an item is deleted.
|
||||
*
|
||||
* @param {ReadingListItem} item - Item deleted.
|
||||
*/
|
||||
onItemDeleted(item) {
|
||||
if (this.isItemForCurrentBrowser(item)) {
|
||||
this.setToolbarButtonState(false);
|
||||
}
|
||||
},
|
||||
};
|
|
@ -146,11 +146,6 @@
|
|||
sidebarurl="chrome://browser/content/history/history-panel.xul"
|
||||
oncommand="SidebarUI.toggle('viewHistorySidebar');"/>
|
||||
|
||||
<broadcaster id="readingListSidebar" hidden="true" autoCheck="false" disabled="true"
|
||||
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');"/>
|
||||
|
@ -421,11 +416,6 @@
|
|||
#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"/>
|
||||
|
|
|
@ -11,9 +11,6 @@ XPCOMUtils.defineLazyModuleGetter(this, "CloudSync",
|
|||
let CloudSync = null;
|
||||
#endif
|
||||
|
||||
XPCOMUtils.defineLazyModuleGetter(this, "ReadingListScheduler",
|
||||
"resource:///modules/readinglist/Scheduler.jsm");
|
||||
|
||||
// gSyncUI handles updating the tools menu and displaying notifications.
|
||||
let gSyncUI = {
|
||||
_obs: ["weave:service:sync:start",
|
||||
|
@ -31,10 +28,6 @@ let gSyncUI = {
|
|||
"weave:ui:sync:error",
|
||||
"weave:ui:sync:finish",
|
||||
"weave:ui:clear-error",
|
||||
|
||||
"readinglist:sync:start",
|
||||
"readinglist:sync:finish",
|
||||
"readinglist:sync:error",
|
||||
],
|
||||
|
||||
_unloaded: false,
|
||||
|
@ -115,17 +108,13 @@ let gSyncUI = {
|
|||
// authManager, so this should always return a value directly.
|
||||
// This only applies to fxAccounts-based Sync.
|
||||
if (Weave.Status._authManager._signedInUser !== undefined) {
|
||||
// So we are using Firefox accounts - in this world, checking Sync isn't
|
||||
// enough as reading list may be configured but not Sync.
|
||||
// We consider ourselves setup if we have a verified user.
|
||||
// XXX - later we should consider checking preferences to ensure at least
|
||||
// one engine is enabled?
|
||||
// If we have a signed in user already, and that user is not verified,
|
||||
// revert to the "needs setup" state.
|
||||
return !Weave.Status._authManager._signedInUser ||
|
||||
!Weave.Status._authManager._signedInUser.verified;
|
||||
}
|
||||
|
||||
// So we are using legacy sync, and reading-list isn't supported for such
|
||||
// users, so check sync itself.
|
||||
// We are using legacy sync - check that.
|
||||
let firstSync = "";
|
||||
try {
|
||||
firstSync = Services.prefs.getCharPref("services.sync.firstSync");
|
||||
|
@ -136,10 +125,9 @@ let gSyncUI = {
|
|||
},
|
||||
|
||||
_loginFailed: function () {
|
||||
this.log.debug("_loginFailed has sync state=${sync}, readinglist state=${rl}",
|
||||
{ sync: Weave.Status.login, rl: ReadingListScheduler.state});
|
||||
return Weave.Status.login == Weave.LOGIN_FAILED_LOGIN_REJECTED ||
|
||||
ReadingListScheduler.state == ReadingListScheduler.STATE_ERROR_AUTHENTICATION;
|
||||
this.log.debug("_loginFailed has sync state=${sync}",
|
||||
{ sync: Weave.Status.login});
|
||||
return Weave.Status.login == Weave.LOGIN_FAILED_LOGIN_REJECTED;
|
||||
},
|
||||
|
||||
updateUI: function SUI_updateUI() {
|
||||
|
@ -235,8 +223,6 @@ let gSyncUI = {
|
|||
|
||||
onLoginError: function SUI_onLoginError() {
|
||||
this.log.debug("onLoginError: login=${login}, sync=${sync}", Weave.Status);
|
||||
// Note: This is used for *both* Sync and ReadingList login errors.
|
||||
// if login fails, any other notifications are essentially moot
|
||||
Weave.Notifications.removeAll();
|
||||
|
||||
// if we haven't set up the client, don't show errors
|
||||
|
@ -260,12 +246,10 @@ let gSyncUI = {
|
|||
},
|
||||
|
||||
showLoginError() {
|
||||
// Note: This is used for *both* Sync and ReadingList login errors.
|
||||
let title = this._stringBundle.GetStringFromName("error.login.title");
|
||||
|
||||
let description;
|
||||
if (Weave.Status.sync == Weave.PROLONGED_SYNC_FAILURE ||
|
||||
this.isProlongedReadingListError()) {
|
||||
if (Weave.Status.sync == Weave.PROLONGED_SYNC_FAILURE) {
|
||||
this.log.debug("showLoginError has a prolonged login error");
|
||||
// Convert to days
|
||||
let lastSync =
|
||||
|
@ -333,7 +317,6 @@ let gSyncUI = {
|
|||
}
|
||||
|
||||
Services.obs.notifyObservers(null, "cloudsync:user-sync", null);
|
||||
Services.obs.notifyObservers(null, "readinglist:user-sync", null);
|
||||
},
|
||||
|
||||
handleToolbarButton: function SUI_handleStatusbarButton() {
|
||||
|
@ -432,14 +415,6 @@ let gSyncUI = {
|
|||
lastSync = new Date(Services.prefs.getCharPref("services.sync.lastSync"));
|
||||
}
|
||||
catch (e) { };
|
||||
// and reading-list time - we want whatever one is the most recent.
|
||||
try {
|
||||
let lastRLSync = new Date(Services.prefs.getCharPref("readinglist.scheduler.lastSync"));
|
||||
if (!lastSync || lastRLSync > lastSync) {
|
||||
lastSync = lastRLSync;
|
||||
}
|
||||
}
|
||||
catch (e) { };
|
||||
if (!lastSync || this._needsSetup()) {
|
||||
if (syncButton) {
|
||||
syncButton.removeAttribute("tooltiptext");
|
||||
|
@ -475,75 +450,6 @@ let gSyncUI = {
|
|||
this.clearError(title);
|
||||
},
|
||||
|
||||
// Return true if the reading-list is in a "prolonged" error state. That
|
||||
// engine doesn't impose what that means, so calculate it here. For
|
||||
// consistency, we just use the sync prefs.
|
||||
isProlongedReadingListError() {
|
||||
// If the readinglist scheduler is disabled we don't treat it as prolonged.
|
||||
let enabled = false;
|
||||
try {
|
||||
enabled = Services.prefs.getBoolPref("readinglist.scheduler.enabled");
|
||||
} catch (_) {}
|
||||
if (!enabled) {
|
||||
return false;
|
||||
}
|
||||
let lastSync, threshold, prolonged;
|
||||
try {
|
||||
lastSync = new Date(Services.prefs.getCharPref("readinglist.scheduler.lastSync"));
|
||||
threshold = new Date(Date.now() - Services.prefs.getIntPref("services.sync.errorhandler.networkFailureReportTimeout") * 1000);
|
||||
prolonged = lastSync <= threshold;
|
||||
} catch (ex) {
|
||||
// no pref, assume not prolonged.
|
||||
prolonged = false;
|
||||
}
|
||||
this.log.debug("isProlongedReadingListError has last successful sync at ${lastSync}, threshold is ${threshold}, prolonged=${prolonged}",
|
||||
{lastSync, threshold, prolonged});
|
||||
return prolonged;
|
||||
},
|
||||
|
||||
onRLSyncError() {
|
||||
// Like onSyncError, but from the reading-list engine.
|
||||
// However, the current UX around Sync is that error notifications should
|
||||
// generally *not* be seen as they typically aren't actionable - so only
|
||||
// authentication errors (which require user action) and "prolonged" errors
|
||||
// (which technically aren't actionable, but user really should know anyway)
|
||||
// are shown.
|
||||
this.log.debug("onRLSyncError with readingList state", ReadingListScheduler.state);
|
||||
if (ReadingListScheduler.state == ReadingListScheduler.STATE_ERROR_AUTHENTICATION) {
|
||||
this.onLoginError();
|
||||
return;
|
||||
}
|
||||
// If it's not prolonged there's nothing to do.
|
||||
if (!this.isProlongedReadingListError()) {
|
||||
this.log.debug("onRLSyncError has a non-authentication, non-prolonged error, so not showing any error UI");
|
||||
return;
|
||||
}
|
||||
// So it's a prolonged error.
|
||||
// Unfortunate duplication from below...
|
||||
this.log.debug("onRLSyncError has a prolonged error");
|
||||
let title = this._stringBundle.GetStringFromName("error.sync.title");
|
||||
// XXX - this is somewhat wrong - we are reporting the threshold we consider
|
||||
// to be prolonged, not how long it actually has been. (ie, lastSync below
|
||||
// is effectively constant) - bit it too is copied from below.
|
||||
let lastSync =
|
||||
Services.prefs.getIntPref("services.sync.errorhandler.networkFailureReportTimeout") / 86400;
|
||||
let description =
|
||||
this._stringBundle.formatStringFromName("error.sync.prolonged_failure", [lastSync], 1);
|
||||
let priority = Weave.Notifications.PRIORITY_INFO;
|
||||
let buttons = [
|
||||
new Weave.NotificationButton(
|
||||
this._stringBundle.GetStringFromName("error.sync.tryAgainButton.label"),
|
||||
this._stringBundle.GetStringFromName("error.sync.tryAgainButton.accesskey"),
|
||||
function() { gSyncUI.doSync(); return true; }
|
||||
),
|
||||
];
|
||||
let notification =
|
||||
new Weave.Notification(title, description, null, priority, buttons);
|
||||
Weave.Notifications.replaceTitle(notification);
|
||||
|
||||
this.updateUI();
|
||||
},
|
||||
|
||||
onSyncError: function SUI_onSyncError() {
|
||||
this.log.debug("onSyncError: login=${login}, sync=${sync}", Weave.Status);
|
||||
let title = this._stringBundle.GetStringFromName("error.sync.title");
|
||||
|
@ -637,21 +543,17 @@ let gSyncUI = {
|
|||
switch (topic) {
|
||||
case "weave:service:sync:start":
|
||||
case "weave:service:login:start":
|
||||
case "readinglist:sync:start":
|
||||
this.onActivityStart();
|
||||
break;
|
||||
case "weave:service:sync:finish":
|
||||
case "weave:service:sync:error":
|
||||
case "weave:service:login:finish":
|
||||
case "weave:service:login:error":
|
||||
case "readinglist:sync:finish":
|
||||
case "readinglist:sync:error":
|
||||
this.onActivityStop();
|
||||
break;
|
||||
}
|
||||
// Now non-activity state (eg, enabled, errors, etc)
|
||||
// Note that sync uses the ":ui:" notifications for errors because sync.
|
||||
// ReadingList has no such concept (yet?; hopefully the :error is enough!)
|
||||
switch (topic) {
|
||||
case "weave:ui:sync:finish":
|
||||
this.onSyncFinish();
|
||||
|
@ -689,13 +591,6 @@ let gSyncUI = {
|
|||
case "weave:ui:clear-error":
|
||||
this.clearError();
|
||||
break;
|
||||
|
||||
case "readinglist:sync:error":
|
||||
this.onRLSyncError();
|
||||
break;
|
||||
case "readinglist:sync:finish":
|
||||
this.clearError();
|
||||
break;
|
||||
}
|
||||
},
|
||||
|
||||
|
|
|
@ -274,7 +274,6 @@ 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
|
||||
|
@ -1267,8 +1266,6 @@ var gBrowserInit = {
|
|||
#ifdef E10S_TESTING_ONLY
|
||||
gRemoteTabsUI.init();
|
||||
#endif
|
||||
ReadingListUI.init();
|
||||
|
||||
// Initialize the full zoom setting.
|
||||
// We do this before the session restore service gets initialized so we can
|
||||
// apply full zoom settings to tabs restored by the session restore service.
|
||||
|
@ -1549,8 +1546,6 @@ var gBrowserInit = {
|
|||
|
||||
gMenuButtonUpdateBadge.uninit();
|
||||
|
||||
ReadingListUI.uninit();
|
||||
|
||||
SidebarUI.uninit();
|
||||
|
||||
// Now either cancel delayedStartup, or clean up the services initialized from
|
||||
|
@ -2549,8 +2544,6 @@ function UpdatePageProxyState()
|
|||
function SetPageProxyState(aState)
|
||||
{
|
||||
BookmarkingUI.onPageProxyStateChanged(aState);
|
||||
ReadingListUI.onPageProxyStateChanged(aState);
|
||||
|
||||
if (!gURLBar)
|
||||
return;
|
||||
|
||||
|
|
|
@ -784,10 +784,6 @@
|
|||
hidden="true"
|
||||
tooltiptext="&pageReportIcon.tooltip;"
|
||||
onclick="gPopupBlockerObserver.onReportButtonClick(event);"/>
|
||||
<image id="readinglist-addremove-button"
|
||||
class="urlbar-icon"
|
||||
hidden="true"
|
||||
onclick="ReadingListUI.buttonClick(event);"/>
|
||||
<image id="reader-mode-button"
|
||||
class="urlbar-icon"
|
||||
hidden="true"
|
||||
|
@ -918,22 +914,6 @@
|
|||
new PlacesMenu(event, 'place:folder=UNFILED_BOOKMARKS',
|
||||
PlacesUIUtils.getViewForNode(this.parentNode.parentNode).options);"/>
|
||||
</menu>
|
||||
<menuseparator>
|
||||
<observes element="readingListSidebar" attribute="hidden"/>
|
||||
</menuseparator>
|
||||
<menu id="BMB_readingList"
|
||||
class="menu-iconic bookmark-item subviewbutton"
|
||||
label="&readingList.label;"
|
||||
container="true">
|
||||
<observes element="readingListSidebar" attribute="hidden"/>
|
||||
<menupopup id="BMB_readingListPopup"
|
||||
placespopup="true"
|
||||
onpopupshowing="ReadingListUI.onReadingListPopupShowing(this);">
|
||||
<menuitem id="BMB_viewReadingListSidebar" class="subviewbutton"
|
||||
oncommand="SidebarUI.show('readingListSidebar');"
|
||||
label="&readingList.showSidebar.label;"/>
|
||||
</menupopup>
|
||||
</menu>
|
||||
<menuseparator/>
|
||||
<!-- Bookmarks menu items will go here -->
|
||||
<menuitem id="BMB_bookmarksShowAll"
|
||||
|
|
|
@ -81,11 +81,22 @@ ContentSearchUIController.prototype = {
|
|||
return this._defaultEngine;
|
||||
},
|
||||
|
||||
set defaultEngine(val) {
|
||||
this._defaultEngine = val;
|
||||
set defaultEngine(engine) {
|
||||
let icon;
|
||||
if (engine.iconBuffer) {
|
||||
icon = this._getFaviconURIFromBuffer(engine.iconBuffer);
|
||||
}
|
||||
else {
|
||||
icon = this._getImageURIForCurrentResolution(
|
||||
"chrome://mozapps/skin/places/defaultFavicon.png");
|
||||
}
|
||||
this._defaultEngine = {
|
||||
name: engine.name,
|
||||
icon: icon,
|
||||
};
|
||||
this._updateDefaultEngineHeader();
|
||||
|
||||
if (val && document.activeElement == this.input) {
|
||||
if (engine && document.activeElement == this.input) {
|
||||
this._speculativeConnect();
|
||||
}
|
||||
},
|
||||
|
@ -96,10 +107,6 @@ ContentSearchUIController.prototype = {
|
|||
|
||||
set engines(val) {
|
||||
this._engines = val;
|
||||
if (!this._table.hidden) {
|
||||
this._setUpOneOffButtons();
|
||||
return;
|
||||
}
|
||||
this._pendingOneOffRefresh = true;
|
||||
},
|
||||
|
||||
|
@ -127,6 +134,9 @@ ContentSearchUIController.prototype = {
|
|||
let allElts = [...this._suggestionsList.children,
|
||||
...this._oneOffButtons,
|
||||
document.getElementById("contentSearchSettingsButton")];
|
||||
// If we are selecting a suggestion and a one-off is selected, don't deselect it.
|
||||
let excludeIndex = idx < this.numSuggestions && this.selectedButtonIndex > -1 ?
|
||||
this.numSuggestions + this.selectedButtonIndex : -1;
|
||||
for (let i = 0; i < allElts.length; ++i) {
|
||||
let elt = allElts[i];
|
||||
let ariaSelectedElt = i < this.numSuggestions ? elt.firstChild : elt;
|
||||
|
@ -135,16 +145,43 @@ ContentSearchUIController.prototype = {
|
|||
ariaSelectedElt.setAttribute("aria-selected", "true");
|
||||
this.input.setAttribute("aria-activedescendant", ariaSelectedElt.id);
|
||||
}
|
||||
else {
|
||||
else if (i != excludeIndex) {
|
||||
elt.classList.remove("selected");
|
||||
ariaSelectedElt.setAttribute("aria-selected", "false");
|
||||
}
|
||||
}
|
||||
},
|
||||
|
||||
get selectedButtonIndex() {
|
||||
let elts = [...this._oneOffButtons,
|
||||
document.getElementById("contentSearchSettingsButton")];
|
||||
for (let i = 0; i < elts.length; ++i) {
|
||||
if (elts[i].classList.contains("selected")) {
|
||||
return i;
|
||||
}
|
||||
}
|
||||
return -1;
|
||||
},
|
||||
|
||||
set selectedButtonIndex(idx) {
|
||||
let elts = [...this._oneOffButtons,
|
||||
document.getElementById("contentSearchSettingsButton")];
|
||||
for (let i = 0; i < elts.length; ++i) {
|
||||
let elt = elts[i];
|
||||
if (i == idx) {
|
||||
elt.classList.add("selected");
|
||||
elt.setAttribute("aria-selected", "true");
|
||||
}
|
||||
else {
|
||||
elt.classList.remove("selected");
|
||||
elt.setAttribute("aria-selected", "false");
|
||||
}
|
||||
}
|
||||
},
|
||||
|
||||
get selectedEngineName() {
|
||||
let selectedElt = this._table.querySelector(".selected");
|
||||
if (selectedElt && selectedElt.engineName) {
|
||||
let selectedElt = this._oneOffsTable.querySelector(".selected");
|
||||
if (selectedElt) {
|
||||
return selectedElt.engineName;
|
||||
}
|
||||
return this.defaultEngine.name;
|
||||
|
@ -194,7 +231,7 @@ ContentSearchUIController.prototype = {
|
|||
},
|
||||
|
||||
_onCommand: function(aEvent) {
|
||||
if (this.selectedIndex == this.numSuggestions + this._oneOffButtons.length) {
|
||||
if (this.selectedButtonIndex == this._oneOffButtons.length) {
|
||||
// Settings button was selected.
|
||||
this._sendMsg("ManageEngines");
|
||||
return;
|
||||
|
@ -264,19 +301,58 @@ ContentSearchUIController.prototype = {
|
|||
|
||||
_onKeypress: function (event) {
|
||||
let selectedIndexDelta = 0;
|
||||
let selectedSuggestionDelta = 0;
|
||||
let selectedOneOffDelta = 0;
|
||||
|
||||
switch (event.keyCode) {
|
||||
case event.DOM_VK_UP:
|
||||
if (!this._table.hidden) {
|
||||
selectedIndexDelta = -1;
|
||||
if (this._table.hidden) {
|
||||
return;
|
||||
}
|
||||
if (event.getModifierState("Accel")) {
|
||||
if (event.shiftKey) {
|
||||
selectedSuggestionDelta = -1;
|
||||
break;
|
||||
}
|
||||
this._cycleCurrentEngine(true);
|
||||
break;
|
||||
}
|
||||
if (event.altKey) {
|
||||
selectedOneOffDelta = -1;
|
||||
break;
|
||||
}
|
||||
selectedIndexDelta = -1;
|
||||
break;
|
||||
case event.DOM_VK_DOWN:
|
||||
if (this._table.hidden) {
|
||||
this._getSuggestions();
|
||||
return;
|
||||
}
|
||||
else {
|
||||
selectedIndexDelta = 1;
|
||||
if (event.getModifierState("Accel")) {
|
||||
if (event.shiftKey) {
|
||||
selectedSuggestionDelta = 1;
|
||||
break;
|
||||
}
|
||||
this._cycleCurrentEngine(false);
|
||||
break;
|
||||
}
|
||||
if (event.altKey) {
|
||||
selectedOneOffDelta = 1;
|
||||
break;
|
||||
}
|
||||
selectedIndexDelta = 1;
|
||||
break;
|
||||
case event.DOM_VK_TAB:
|
||||
if (this._table.hidden) {
|
||||
return;
|
||||
}
|
||||
// Shift+tab when either the first or no one-off is selected, as well as
|
||||
// tab when the settings button is selected, should change focus as normal.
|
||||
if ((this.selectedButtonIndex <= 0 && event.shiftKey) ||
|
||||
this.selectedButtonIndex == this._oneOffButtons.length && !event.shiftKey) {
|
||||
return;
|
||||
}
|
||||
selectedOneOffDelta = event.shiftKey ? -1 : 1;
|
||||
break;
|
||||
case event.DOM_VK_RIGHT:
|
||||
// Allow normal caret movement until the caret is at the end of the input.
|
||||
|
@ -297,37 +373,89 @@ ContentSearchUIController.prototype = {
|
|||
}
|
||||
this._stickyInputValue = this.input.value;
|
||||
this._hideSuggestions();
|
||||
break;
|
||||
return;
|
||||
case event.DOM_VK_RETURN:
|
||||
this._onCommand(event);
|
||||
break;
|
||||
return;
|
||||
case event.DOM_VK_DELETE:
|
||||
if (this.selectedIndex >= 0) {
|
||||
this.deleteSuggestionAtIndex(this.selectedIndex);
|
||||
}
|
||||
break;
|
||||
return;
|
||||
case event.DOM_VK_ESCAPE:
|
||||
if (!this._table.hidden) {
|
||||
this._hideSuggestions();
|
||||
}
|
||||
return;
|
||||
default:
|
||||
return;
|
||||
}
|
||||
|
||||
let currentIndex = this.selectedIndex;
|
||||
if (selectedIndexDelta) {
|
||||
// Update the selection.
|
||||
let newSelectedIndex = this.selectedIndex + selectedIndexDelta;
|
||||
let newSelectedIndex = currentIndex + selectedIndexDelta;
|
||||
if (newSelectedIndex < -1) {
|
||||
newSelectedIndex = this.numSuggestions + this._oneOffButtons.length;
|
||||
}
|
||||
else if (this.numSuggestions + this._oneOffButtons.length < newSelectedIndex) {
|
||||
// If are moving up from the first one off, we have to deselect the one off
|
||||
// manually because the selectedIndex setter tries to exclude the selected
|
||||
// one-off (which is desirable for accel+shift+up/down).
|
||||
if (currentIndex == this.numSuggestions && selectedIndexDelta == -1) {
|
||||
this.selectedButtonIndex = -1;
|
||||
}
|
||||
this.selectAndUpdateInput(newSelectedIndex);
|
||||
}
|
||||
|
||||
else if (selectedSuggestionDelta) {
|
||||
let newSelectedIndex;
|
||||
if (currentIndex >= this.numSuggestions || currentIndex == -1) {
|
||||
// No suggestion already selected, select the first/last one appropriately.
|
||||
newSelectedIndex = selectedSuggestionDelta == 1 ?
|
||||
0 : this.numSuggestions - 1;
|
||||
}
|
||||
else {
|
||||
newSelectedIndex = currentIndex + selectedSuggestionDelta;
|
||||
}
|
||||
if (newSelectedIndex >= this.numSuggestions) {
|
||||
newSelectedIndex = -1;
|
||||
}
|
||||
this.selectAndUpdateInput(newSelectedIndex);
|
||||
|
||||
// Prevent the input's caret from moving.
|
||||
event.preventDefault();
|
||||
}
|
||||
|
||||
else if (selectedOneOffDelta) {
|
||||
let newSelectedIndex;
|
||||
let currentButton = this.selectedButtonIndex;
|
||||
if (currentButton == -1 || currentButton == this._oneOffButtons.length) {
|
||||
// No one-off already selected, select the first/last one appropriately.
|
||||
newSelectedIndex = selectedOneOffDelta == 1 ?
|
||||
0 : this._oneOffButtons.length - 1;
|
||||
}
|
||||
else {
|
||||
newSelectedIndex = currentButton + selectedOneOffDelta;
|
||||
}
|
||||
// Allow selection of the settings button via the tab key.
|
||||
if (newSelectedIndex == this._oneOffButtons.length &&
|
||||
event.keyCode != event.DOM_VK_TAB) {
|
||||
newSelectedIndex = -1;
|
||||
}
|
||||
this.selectedButtonIndex = newSelectedIndex;
|
||||
}
|
||||
|
||||
// Prevent the input's caret from moving.
|
||||
event.preventDefault();
|
||||
},
|
||||
|
||||
_currentEngineIndex: -1,
|
||||
_cycleCurrentEngine: function (aReverse) {
|
||||
if ((this._currentEngineIndex == this._oneOffButtons.length - 1 && !aReverse) ||
|
||||
(this._currentEngineIndex < 0 && aReverse)) {
|
||||
return;
|
||||
}
|
||||
this._currentEngineIndex += aReverse ? -1 : 1;
|
||||
let engineName = this._currentEngineIndex > -1 ?
|
||||
this._oneOffButtons[this._currentEngineIndex].engineName :
|
||||
this._originalDefaultEngine.name;
|
||||
this._sendMsg("SetCurrentEngine", engineName);
|
||||
},
|
||||
|
||||
_onFocus: function () {
|
||||
|
@ -356,7 +484,12 @@ ContentSearchUIController.prototype = {
|
|||
},
|
||||
|
||||
_onMousemove: function (event) {
|
||||
this.selectedIndex = this._indexOfTableItem(event.target);
|
||||
let idx = this._indexOfTableItem(event.target);
|
||||
if (idx >= this.numSuggestions) {
|
||||
this.selectedButtonIndex = idx - this.numSuggestions;
|
||||
return;
|
||||
}
|
||||
this.selectedIndex = idx;
|
||||
},
|
||||
|
||||
_onMouseup: function (event) {
|
||||
|
@ -366,6 +499,15 @@ ContentSearchUIController.prototype = {
|
|||
this._onCommand(event);
|
||||
},
|
||||
|
||||
_onMouseout: function (event) {
|
||||
// We only deselect one-off buttons and the settings button when they are
|
||||
// moused out.
|
||||
let idx = this._indexOfTableItem(event.originalTarget);
|
||||
if (idx >= this.numSuggestions) {
|
||||
this.selectedButtonIndex = -1;
|
||||
}
|
||||
},
|
||||
|
||||
_onClick: function (event) {
|
||||
this._onMouseup(event);
|
||||
},
|
||||
|
@ -427,15 +569,22 @@ ContentSearchUIController.prototype = {
|
|||
}
|
||||
this._table.hidden = false;
|
||||
this.input.setAttribute("aria-expanded", "true");
|
||||
this._originalDefaultEngine = {
|
||||
name: this.defaultEngine.name,
|
||||
icon: this.defaultEngine.icon,
|
||||
};
|
||||
}
|
||||
},
|
||||
|
||||
_onMsgState: function (state) {
|
||||
this.defaultEngine = {
|
||||
name: state.currentEngine.name,
|
||||
icon: this._getFaviconURIFromBuffer(state.currentEngine.iconBuffer),
|
||||
};
|
||||
this.engines = state.engines;
|
||||
// No point updating the default engine (and the header) if there's no change.
|
||||
if (this.defaultEngine &&
|
||||
this.defaultEngine.name == state.currentEngine.name &&
|
||||
this.defaultEngine.icon == state.currentEngine.icon) {
|
||||
return;
|
||||
}
|
||||
this.defaultEngine = state.currentEngine;
|
||||
},
|
||||
|
||||
_onMsgCurrentState: function (state) {
|
||||
|
@ -443,14 +592,7 @@ ContentSearchUIController.prototype = {
|
|||
},
|
||||
|
||||
_onMsgCurrentEngine: function (engine) {
|
||||
this.defaultEngine = {
|
||||
name: engine.name,
|
||||
icon: this._getFaviconURIFromBuffer(engine.iconBuffer),
|
||||
};
|
||||
if (!this._table.hidden) {
|
||||
this._setUpOneOffButtons();
|
||||
return;
|
||||
}
|
||||
this.defaultEngine = engine;
|
||||
this._pendingOneOffRefresh = true;
|
||||
},
|
||||
|
||||
|
@ -464,9 +606,7 @@ ContentSearchUIController.prototype = {
|
|||
|
||||
_updateDefaultEngineHeader: function () {
|
||||
let header = document.getElementById("contentSearchDefaultEngineHeader");
|
||||
if (this.defaultEngine.icon) {
|
||||
header.firstChild.setAttribute("src", this.defaultEngine.icon);
|
||||
}
|
||||
header.firstChild.setAttribute("src", this.defaultEngine.icon);
|
||||
if (!this._strings) {
|
||||
return;
|
||||
}
|
||||
|
@ -545,6 +685,14 @@ ContentSearchUIController.prototype = {
|
|||
return URL.createObjectURL(blob) + "#-moz-resolution=" + sizeStr;
|
||||
},
|
||||
|
||||
// Adds "@2x" to the name of the given PNG url for "retina" screens.
|
||||
_getImageURIForCurrentResolution: function (uri) {
|
||||
if (window.devicePixelRatio > 1) {
|
||||
return uri.replace(/\.png$/, "@2x.png");
|
||||
}
|
||||
return uri;
|
||||
},
|
||||
|
||||
_getSearchEngines: function () {
|
||||
this._sendMsg("GetState");
|
||||
},
|
||||
|
@ -572,6 +720,9 @@ ContentSearchUIController.prototype = {
|
|||
|
||||
_hideSuggestions: function () {
|
||||
this.input.setAttribute("aria-expanded", "false");
|
||||
this.selectedIndex = -1;
|
||||
this.selectedButtonIndex = -1;
|
||||
this._currentEngineIndex = -1;
|
||||
this._table.hidden = true;
|
||||
},
|
||||
|
||||
|
@ -605,11 +756,7 @@ ContentSearchUIController.prototype = {
|
|||
document.addEventListener("mouseup", () => { delete this._mousedown; });
|
||||
|
||||
// Deselect the selected element on mouseout if it wasn't a suggestion.
|
||||
this._table.addEventListener("mouseout", () => {
|
||||
if (this.selectedIndex >= this.numSuggestions) {
|
||||
this.selectAndUpdateInput(-1);
|
||||
}
|
||||
});
|
||||
this._table.addEventListener("mouseout", this);
|
||||
|
||||
// If a search is loaded in the same tab, ensure the suggestions dropdown
|
||||
// is hidden immediately when the page starts loading and not when it first
|
||||
|
@ -620,9 +767,8 @@ ContentSearchUIController.prototype = {
|
|||
let header = document.createElementNS(HTML_NS, "td");
|
||||
headerRow.setAttribute("class", "contentSearchHeaderRow");
|
||||
header.setAttribute("class", "contentSearchHeader");
|
||||
let img = document.createElementNS(HTML_NS, "img");
|
||||
img.setAttribute("src", "chrome://browser/skin/search-engine-placeholder.png");
|
||||
header.appendChild(img);
|
||||
let iconImg = document.createElementNS(HTML_NS, "img");
|
||||
header.appendChild(iconImg);
|
||||
header.id = "contentSearchDefaultEngineHeader";
|
||||
headerRow.appendChild(header);
|
||||
headerRow.addEventListener("click", this);
|
||||
|
@ -707,10 +853,14 @@ ContentSearchUIController.prototype = {
|
|||
let button = document.createElementNS(HTML_NS, "button");
|
||||
button.setAttribute("class", "contentSearchOneOffItem");
|
||||
let img = document.createElementNS(HTML_NS, "img");
|
||||
let uri = "chrome://browser/skin/search-engine-placeholder.png";
|
||||
let uri;
|
||||
if (engine.iconBuffer) {
|
||||
uri = this._getFaviconURIFromBuffer(engine.iconBuffer);
|
||||
}
|
||||
else {
|
||||
uri = this._getImageURIForCurrentResolution(
|
||||
"chrome://browser/skin/search-engine-placeholder.png");
|
||||
}
|
||||
img.setAttribute("src", uri);
|
||||
button.appendChild(img);
|
||||
button.style.width = buttonWidth + "px";
|
||||
|
|
|
@ -6,15 +6,6 @@
|
|||
|
||||
Components.utils.import("resource://gre/modules/Services.jsm");
|
||||
|
||||
addEventListener("load", function () {
|
||||
// unhide the reading-list engine if readinglist is enabled (note this
|
||||
// dialog is only used with FxA sync, so no special action is needed
|
||||
// for legacy sync.)
|
||||
if (Services.prefs.getBoolPref("browser.readinglist.enabled")) {
|
||||
document.getElementById("readinglist-engine").removeAttribute("hidden");
|
||||
}
|
||||
});
|
||||
|
||||
addEventListener("dialogaccept", function () {
|
||||
let pane = document.getElementById("sync-customize-pane");
|
||||
// First determine what the preference for the "global" sync enabled pref
|
||||
|
|
|
@ -27,8 +27,6 @@
|
|||
<preference id="engine.passwords" name="services.sync.engine.passwords" type="bool"/>
|
||||
<preference id="engine.addons" name="services.sync.engine.addons" type="bool"/>
|
||||
<preference id="engine.prefs" name="services.sync.engine.prefs" type="bool"/>
|
||||
<!-- non Sync-Engine engines -->
|
||||
<preference id="engine.readinglist" name="readinglist.scheduler.enabled" type="bool"/>
|
||||
</preferences>
|
||||
|
||||
<label id="sync-customize-title" value="&syncCustomize.title;"/>
|
||||
|
@ -53,11 +51,6 @@
|
|||
<checkbox label="&engine.history.label;"
|
||||
accesskey="&engine.history.accesskey;"
|
||||
preference="engine.history"/>
|
||||
<checkbox id="readinglist-engine"
|
||||
label="&engine.readinglist.label;"
|
||||
accesskey="&engine.readinglist.accesskey;"
|
||||
preference="engine.readinglist"
|
||||
hidden="true"/>
|
||||
<checkbox label="&engine.addons.label;"
|
||||
accesskey="&engine.addons.accesskey;"
|
||||
preference="engine.addons"/>
|
||||
|
|
|
@ -9,6 +9,7 @@ let {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/ExtensionContent.jsm");
|
||||
|
||||
XPCOMUtils.defineLazyModuleGetter(this, "E10SUtils",
|
||||
"resource:///modules/E10SUtils.jsm");
|
||||
|
@ -656,3 +657,8 @@ let DOMFullscreenHandler = {
|
|||
}
|
||||
};
|
||||
DOMFullscreenHandler.init();
|
||||
|
||||
ExtensionContent.init(this);
|
||||
addEventListener("unload", () => {
|
||||
ExtensionContent.uninit(this);
|
||||
});
|
||||
|
|
|
@ -454,6 +454,7 @@ skip-if = os == "linux" || e10s # Bug 1073339 - Investigate autocomplete test un
|
|||
[browser_urlbarSearchSingleWordNotification.js]
|
||||
[browser_urlbarStop.js]
|
||||
[browser_urlbarTrimURLs.js]
|
||||
[browser_urlbar_autoFill_backspaced.js]
|
||||
[browser_urlbar_search_healthreport.js]
|
||||
[browser_urlbar_searchsettings.js]
|
||||
[browser_utilityOverlay.js]
|
||||
|
|
|
@ -9,6 +9,8 @@ const TEST_ENGINE_2_BASENAME = "searchSuggestionEngine2.xml";
|
|||
|
||||
const TEST_MSG = "ContentSearchUIControllerTest";
|
||||
|
||||
requestLongerTimeout(2);
|
||||
|
||||
add_task(function* emptyInput() {
|
||||
yield setUp();
|
||||
|
||||
|
@ -102,7 +104,7 @@ add_task(function* rightLeftKeys() {
|
|||
// trigger suggestions again and cycle through them by pressing Down until
|
||||
// nothing is selected again.
|
||||
state = yield msg("key", "VK_RIGHT");
|
||||
checkState(state, "xfoo", [], 0);
|
||||
checkState(state, "xfoo", [], -1);
|
||||
|
||||
state = yield msg("key", { key: "VK_DOWN", waitForSuggestions: true });
|
||||
checkState(state, "xfoo", ["xfoofoo", "xfoobar"], -1);
|
||||
|
@ -125,20 +127,202 @@ add_task(function* rightLeftKeys() {
|
|||
yield msg("reset");
|
||||
});
|
||||
|
||||
add_task(function* tabKey() {
|
||||
yield setUp();
|
||||
yield msg("key", { key: "x", waitForSuggestions: true });
|
||||
|
||||
let state = yield msg("key", "VK_TAB");
|
||||
checkState(state, "x", ["xfoo", "xbar"], 2);
|
||||
|
||||
state = yield msg("key", "VK_TAB");
|
||||
checkState(state, "x", ["xfoo", "xbar"], 3);
|
||||
|
||||
state = yield msg("key", { key: "VK_TAB", modifiers: { shiftKey: true }});
|
||||
checkState(state, "x", ["xfoo", "xbar"], 2);
|
||||
|
||||
state = yield msg("key", { key: "VK_TAB", modifiers: { shiftKey: true }});
|
||||
checkState(state, "x", [], -1);
|
||||
|
||||
yield setUp();
|
||||
|
||||
yield msg("key", { key: "VK_DOWN", waitForSuggestions: true });
|
||||
|
||||
for (let i = 0; i < 3; ++i) {
|
||||
state = yield msg("key", "VK_TAB");
|
||||
}
|
||||
checkState(state, "x", [], -1);
|
||||
|
||||
yield setUp();
|
||||
|
||||
yield msg("key", { key: "VK_DOWN", waitForSuggestions: true });
|
||||
state = yield msg("key", "VK_DOWN");
|
||||
checkState(state, "xfoo", ["xfoo", "xbar"], 0);
|
||||
|
||||
state = yield msg("key", "VK_TAB");
|
||||
checkState(state, "xfoo", ["xfoo", "xbar"], 0, 0);
|
||||
|
||||
state = yield msg("key", "VK_TAB");
|
||||
checkState(state, "xfoo", ["xfoo", "xbar"], 0, 1);
|
||||
|
||||
state = yield msg("key", "VK_DOWN");
|
||||
checkState(state, "xbar", ["xfoo", "xbar"], 1, 1);
|
||||
|
||||
state = yield msg("key", "VK_DOWN");
|
||||
checkState(state, "x", ["xfoo", "xbar"], 2);
|
||||
|
||||
state = yield msg("key", "VK_UP");
|
||||
checkState(state, "xbar", ["xfoo", "xbar"], 1);
|
||||
|
||||
state = yield msg("key", "VK_TAB");
|
||||
checkState(state, "xbar", ["xfoo", "xbar"], 1, 0);
|
||||
|
||||
state = yield msg("key", "VK_TAB");
|
||||
checkState(state, "xbar", ["xfoo", "xbar"], 1, 1);
|
||||
|
||||
state = yield msg("key", "VK_TAB");
|
||||
checkState(state, "xbar", [], -1);
|
||||
|
||||
yield msg("reset");
|
||||
});
|
||||
|
||||
add_task(function* cycleSuggestions() {
|
||||
yield setUp();
|
||||
yield msg("key", { key: "x", waitForSuggestions: true });
|
||||
|
||||
let cycle = Task.async(function* (aSelectedButtonIndex) {
|
||||
let modifiers = {
|
||||
shiftKey: true,
|
||||
accelKey: true,
|
||||
};
|
||||
|
||||
let state = yield msg("key", { key: "VK_DOWN", modifiers: modifiers });
|
||||
checkState(state, "xfoo", ["xfoo", "xbar"], 0, aSelectedButtonIndex);
|
||||
|
||||
state = yield msg("key", { key: "VK_DOWN", modifiers: modifiers });
|
||||
checkState(state, "xbar", ["xfoo", "xbar"], 1, aSelectedButtonIndex);
|
||||
|
||||
state = yield msg("key", { key: "VK_DOWN", modifiers: modifiers });
|
||||
checkState(state, "x", ["xfoo", "xbar"], -1, aSelectedButtonIndex);
|
||||
|
||||
state = yield msg("key", { key: "VK_DOWN", modifiers: modifiers });
|
||||
checkState(state, "xfoo", ["xfoo", "xbar"], 0, aSelectedButtonIndex);
|
||||
|
||||
state = yield msg("key", { key: "VK_UP", modifiers: modifiers });
|
||||
checkState(state, "x", ["xfoo", "xbar"], -1, aSelectedButtonIndex);
|
||||
|
||||
state = yield msg("key", { key: "VK_UP", modifiers: modifiers });
|
||||
checkState(state, "xbar", ["xfoo", "xbar"], 1, aSelectedButtonIndex);
|
||||
|
||||
state = yield msg("key", { key: "VK_UP", modifiers: modifiers });
|
||||
checkState(state, "xfoo", ["xfoo", "xbar"], 0, aSelectedButtonIndex);
|
||||
|
||||
state = yield msg("key", { key: "VK_UP", modifiers: modifiers });
|
||||
checkState(state, "x", ["xfoo", "xbar"], -1, aSelectedButtonIndex);
|
||||
});
|
||||
|
||||
yield cycle();
|
||||
|
||||
// Repeat with a one-off selected.
|
||||
let state = yield msg("key", "VK_TAB");
|
||||
checkState(state, "x", ["xfoo", "xbar"], 2);
|
||||
yield cycle(0);
|
||||
|
||||
// Repeat with the settings button selected.
|
||||
state = yield msg("key", "VK_TAB");
|
||||
checkState(state, "x", ["xfoo", "xbar"], 3);
|
||||
yield cycle(1);
|
||||
|
||||
yield msg("reset");
|
||||
});
|
||||
|
||||
add_task(function* cycleOneOffs() {
|
||||
yield setUp();
|
||||
yield msg("key", { key: "x", waitForSuggestions: true });
|
||||
|
||||
yield msg("addDuplicateOneOff");
|
||||
|
||||
let state = yield msg("key", "VK_DOWN");
|
||||
state = yield msg("key", "VK_DOWN");
|
||||
checkState(state, "xbar", ["xfoo", "xbar"], 1);
|
||||
|
||||
let modifiers = {
|
||||
altKey: true,
|
||||
};
|
||||
|
||||
state = yield msg("key", { key: "VK_DOWN", modifiers: modifiers });
|
||||
checkState(state, "xbar", ["xfoo", "xbar"], 1, 0);
|
||||
|
||||
state = yield msg("key", { key: "VK_DOWN", modifiers: modifiers });
|
||||
checkState(state, "xbar", ["xfoo", "xbar"], 1, 1);
|
||||
|
||||
state = yield msg("key", { key: "VK_DOWN", modifiers: modifiers });
|
||||
checkState(state, "xbar", ["xfoo", "xbar"], 1);
|
||||
|
||||
state = yield msg("key", { key: "VK_UP", modifiers: modifiers });
|
||||
checkState(state, "xbar", ["xfoo", "xbar"], 1, 1);
|
||||
|
||||
state = yield msg("key", { key: "VK_UP", modifiers: modifiers });
|
||||
checkState(state, "xbar", ["xfoo", "xbar"], 1, 0);
|
||||
|
||||
state = yield msg("key", { key: "VK_UP", modifiers: modifiers });
|
||||
checkState(state, "xbar", ["xfoo", "xbar"], 1);
|
||||
|
||||
// If the settings button is selected, pressing alt+up/down should select the
|
||||
// last/first one-off respectively (and deselect the settings button).
|
||||
yield msg("key", "VK_TAB");
|
||||
yield msg("key", "VK_TAB");
|
||||
state = yield msg("key", "VK_TAB"); // Settings button selected.
|
||||
checkState(state, "xbar", ["xfoo", "xbar"], 1, 2);
|
||||
|
||||
state = yield msg("key", { key: "VK_UP", modifiers: modifiers });
|
||||
checkState(state, "xbar", ["xfoo", "xbar"], 1, 1);
|
||||
|
||||
state = yield msg("key", "VK_TAB");
|
||||
checkState(state, "xbar", ["xfoo", "xbar"], 1, 2);
|
||||
|
||||
state = yield msg("key", { key: "VK_DOWN", modifiers: modifiers });
|
||||
checkState(state, "xbar", ["xfoo", "xbar"], 1, 0);
|
||||
|
||||
yield msg("removeLastOneOff");
|
||||
yield msg("reset");
|
||||
});
|
||||
|
||||
add_task(function* mouse() {
|
||||
yield setUp();
|
||||
|
||||
let state = yield msg("key", { key: "x", waitForSuggestions: true });
|
||||
checkState(state, "x", ["xfoo", "xbar"], -1);
|
||||
|
||||
for (let i = 0; i < 4; ++i) {
|
||||
state = yield msg("mousemove", i);
|
||||
checkState(state, "x", ["xfoo", "xbar"], i);
|
||||
}
|
||||
state = yield msg("mousemove", 0);
|
||||
checkState(state, "x", ["xfoo", "xbar"], 0);
|
||||
|
||||
state = yield msg("mousemove", 1);
|
||||
checkState(state, "x", ["xfoo", "xbar"], 1);
|
||||
|
||||
state = yield msg("mousemove", 2);
|
||||
checkState(state, "x", ["xfoo", "xbar"], 1, 0);
|
||||
|
||||
state = yield msg("mousemove", 3);
|
||||
checkState(state, "x", ["xfoo", "xbar"], 1, 1);
|
||||
|
||||
state = yield msg("mousemove", -1);
|
||||
checkState(state, "x", ["xfoo", "xbar"], 1);
|
||||
|
||||
yield msg("reset");
|
||||
yield setUp();
|
||||
|
||||
state = yield msg("key", { key: "x", waitForSuggestions: true });
|
||||
checkState(state, "x", ["xfoo", "xbar"], -1);
|
||||
|
||||
state = yield msg("mousemove", 0);
|
||||
checkState(state, "x", ["xfoo", "xbar"], 0);
|
||||
|
||||
state = yield msg("mousemove", 2);
|
||||
checkState(state, "x", ["xfoo", "xbar"], 0, 0);
|
||||
|
||||
state = yield msg("mousemove", -1);
|
||||
checkState(state, "x", ["xfoo", "xbar"], 0);
|
||||
|
||||
yield msg("reset");
|
||||
});
|
||||
|
||||
|
@ -197,6 +381,34 @@ add_task(function* formHistory() {
|
|||
yield msg("reset");
|
||||
});
|
||||
|
||||
add_task(function* cycleEngines() {
|
||||
yield setUp();
|
||||
yield msg("key", { key: "VK_DOWN", waitForSuggestions: true });
|
||||
|
||||
let promiseEngineChange = function(newEngineName) {
|
||||
let deferred = Promise.defer();
|
||||
Services.obs.addObserver(function resolver(subj, topic, data) {
|
||||
if (data != "engine-current") {
|
||||
return;
|
||||
}
|
||||
SimpleTest.is(subj.name, newEngineName, "Engine cycled correctly");
|
||||
Services.obs.removeObserver(resolver, "browser-search-engine-modified");
|
||||
deferred.resolve();
|
||||
}, "browser-search-engine-modified", false);
|
||||
return deferred.promise;
|
||||
}
|
||||
|
||||
let p = promiseEngineChange(TEST_ENGINE_PREFIX + " " + TEST_ENGINE_2_BASENAME);
|
||||
yield msg("key", { key: "VK_DOWN", modifiers: { accelKey: true }});
|
||||
yield p;
|
||||
|
||||
p = promiseEngineChange(TEST_ENGINE_PREFIX + " " + TEST_ENGINE_BASENAME);
|
||||
yield msg("key", { key: "VK_UP", modifiers: { accelKey: true }});
|
||||
yield p;
|
||||
|
||||
yield msg("reset");
|
||||
});
|
||||
|
||||
add_task(function* search() {
|
||||
yield setUp();
|
||||
|
||||
|
@ -297,6 +509,42 @@ add_task(function* search() {
|
|||
yield promiseTab();
|
||||
yield setUp();
|
||||
|
||||
// Test selecting a suggestion, then clicking a one-off without deselecting the
|
||||
// suggestion.
|
||||
yield msg("key", { key: "x", waitForSuggestions: true });
|
||||
p = msg("waitForSearch");
|
||||
yield msg("mousemove", 1);
|
||||
yield msg("mousemove", 3);
|
||||
yield msg("click", { eltIdx: 3, modifiers: modifiers });
|
||||
mesg = yield p;
|
||||
eventData.searchString = "xfoo"
|
||||
eventData.selection = {
|
||||
index: 1,
|
||||
kind: "mouse",
|
||||
};
|
||||
SimpleTest.isDeeply(eventData, mesg, "Search event data");
|
||||
|
||||
yield promiseTab();
|
||||
yield setUp();
|
||||
|
||||
// Same as above, but with the keyboard.
|
||||
delete modifiers.button;
|
||||
yield msg("key", { key: "x", waitForSuggestions: true });
|
||||
p = msg("waitForSearch");
|
||||
yield msg("key", "VK_DOWN");
|
||||
yield msg("key", "VK_DOWN");
|
||||
yield msg("key", "VK_TAB");
|
||||
yield msg("key", { key: "VK_RETURN", modifiers: modifiers });
|
||||
mesg = yield p;
|
||||
eventData.selection = {
|
||||
index: 1,
|
||||
kind: "key",
|
||||
};
|
||||
SimpleTest.isDeeply(eventData, mesg, "Search event data");
|
||||
|
||||
yield promiseTab();
|
||||
yield setUp();
|
||||
|
||||
// Test searching when using IME composition.
|
||||
let state = yield msg("startComposition", { data: "" });
|
||||
checkState(state, "", [], -1);
|
||||
|
@ -308,8 +556,10 @@ add_task(function* search() {
|
|||
p = msg("waitForSearch");
|
||||
yield msg("key", { key: "VK_RETURN", modifiers: modifiers });
|
||||
mesg = yield p;
|
||||
eventData.searchString = "x"
|
||||
eventData.originalEvent = modifiers;
|
||||
eventData.engineName = TEST_ENGINE_PREFIX + " " + TEST_ENGINE_BASENAME;
|
||||
delete eventData.selection;
|
||||
SimpleTest.isDeeply(eventData, mesg, "Search event data");
|
||||
|
||||
yield promiseTab();
|
||||
|
@ -428,7 +678,7 @@ function msg(type, data=null) {
|
|||
}
|
||||
|
||||
function checkState(actualState, expectedInputVal, expectedSuggestions,
|
||||
expectedSelectedIdx) {
|
||||
expectedSelectedIdx, expectedSelectedButtonIdx) {
|
||||
expectedSuggestions = expectedSuggestions.map(sugg => {
|
||||
return typeof(sugg) == "object" ? sugg : {
|
||||
str: sugg,
|
||||
|
@ -436,6 +686,10 @@ function checkState(actualState, expectedInputVal, expectedSuggestions,
|
|||
};
|
||||
});
|
||||
|
||||
if (expectedSelectedIdx == -1 && expectedSelectedButtonIdx != undefined) {
|
||||
expectedSelectedIdx = expectedSuggestions.length + expectedSelectedButtonIdx;
|
||||
}
|
||||
|
||||
let expectedState = {
|
||||
selectedIndex: expectedSelectedIdx,
|
||||
numSuggestions: expectedSuggestions.length,
|
||||
|
@ -448,6 +702,15 @@ function checkState(actualState, expectedInputVal, expectedSuggestions,
|
|||
inputValue: expectedInputVal,
|
||||
ariaExpanded: expectedSuggestions.length == 0 ? "false" : "true",
|
||||
};
|
||||
if (expectedSelectedButtonIdx != undefined) {
|
||||
expectedState.selectedButtonIndex = expectedSelectedButtonIdx;
|
||||
}
|
||||
else if (expectedSelectedIdx < expectedSuggestions.length) {
|
||||
expectedState.selectedButtonIndex = -1;
|
||||
}
|
||||
else {
|
||||
expectedState.selectedButtonIndex = expectedSelectedIdx - expectedSuggestions.length;
|
||||
}
|
||||
|
||||
SimpleTest.isDeeply(actualState, expectedState, "State");
|
||||
}
|
||||
|
|
|
@ -4,13 +4,10 @@
|
|||
|
||||
/**
|
||||
* Test that the reader mode button appears and works properly on
|
||||
* reader-able content, and that ReadingList button can open and close
|
||||
* its Sidebar UI.
|
||||
* reader-able content.
|
||||
*/
|
||||
const TEST_PREFS = [
|
||||
["reader.parse-on-load.enabled", true],
|
||||
["browser.readinglist.enabled", true],
|
||||
["browser.readinglist.introShown", false],
|
||||
];
|
||||
|
||||
const TEST_PATH = "http://example.com/browser/browser/base/content/test/general/";
|
||||
|
@ -63,26 +60,6 @@ add_task(function* test_reader_button() {
|
|||
is(gURLBar.value, readerUrl, "gURLBar value is about:reader URL");
|
||||
is(gURLBar.textValue, url.substring("http://".length), "gURLBar is displaying original article URL");
|
||||
|
||||
// Readinglist button should be present, and status should be "openned", as the
|
||||
// first time in readerMode opens the Sidebar ReadingList as a feature introduction.
|
||||
let listButton;
|
||||
yield promiseWaitForCondition(() =>
|
||||
listButton = gBrowser.contentDocument.getElementById("list-button"));
|
||||
is_element_visible(listButton, "List button is present on a reader-able page");
|
||||
yield promiseWaitForCondition(() => listButton.classList.contains("on"));
|
||||
ok(listButton.classList.contains("on"),
|
||||
"List button should indicate SideBar-ReadingList open.");
|
||||
ok(ReadingListUI.isSidebarOpen,
|
||||
"The ReadingListUI should indicate SideBar-ReadingList open.");
|
||||
|
||||
// Now close the Sidebar ReadingList.
|
||||
listButton.click();
|
||||
yield promiseWaitForCondition(() => !listButton.classList.contains("on"));
|
||||
ok(!listButton.classList.contains("on"),
|
||||
"List button should now indicate SideBar-ReadingList closed.");
|
||||
ok(!ReadingListUI.isSidebarOpen,
|
||||
"The ReadingListUI should now indicate SideBar-ReadingList closed.");
|
||||
|
||||
// Switch page back out of reader mode.
|
||||
readerButton.click();
|
||||
yield promiseTabLoadEvent(tab);
|
||||
|
|
|
@ -4,8 +4,7 @@
|
|||
|
||||
/**
|
||||
* Test that the reader mode button appears and works properly on
|
||||
* reader-able content, and that ReadingList button can open and close
|
||||
* its Sidebar UI.
|
||||
* reader-able content.
|
||||
*/
|
||||
const TEST_PREFS = [
|
||||
["reader.parse-on-load.enabled", true],
|
||||
|
|
|
@ -4,8 +4,6 @@
|
|||
let {Log} = Cu.import("resource://gre/modules/Log.jsm", {});
|
||||
let {Weave} = Cu.import("resource://services-sync/main.js", {});
|
||||
let {Notifications} = Cu.import("resource://services-sync/notifications.js", {});
|
||||
// The BackStagePass allows us to get this test-only non-exported function.
|
||||
let {getInternalScheduler} = Cu.import("resource:///modules/readinglist/Scheduler.jsm", {});
|
||||
|
||||
let stringBundle = Cc["@mozilla.org/intl/stringbundle;1"]
|
||||
.getService(Ci.nsIStringBundleService)
|
||||
|
@ -37,23 +35,6 @@ add_task(function* prepare() {
|
|||
});
|
||||
});
|
||||
|
||||
add_task(function* testNotProlongedRLErrorWhenDisabled() {
|
||||
// Here we arrange for the (dead?) readinglist scheduler to have a last-synced
|
||||
// date of long ago and the RL scheduler is disabled.
|
||||
// gSyncUI.isProlongedReadingListError() should return false.
|
||||
// Pretend the reading-list is in the "prolonged error" state.
|
||||
let longAgo = new Date(Date.now() - 100 * 24 * 60 * 60 * 1000); // 100 days ago.
|
||||
Services.prefs.setCharPref("readinglist.scheduler.lastSync", longAgo.toString());
|
||||
|
||||
// It's prolonged while it's enabled.
|
||||
Services.prefs.setBoolPref("readinglist.scheduler.enabled", true);
|
||||
Assert.equal(gSyncUI.isProlongedReadingListError(), true);
|
||||
|
||||
// But false when disabled.
|
||||
Services.prefs.setBoolPref("readinglist.scheduler.enabled", false);
|
||||
Assert.equal(gSyncUI.isProlongedReadingListError(), false);
|
||||
});
|
||||
|
||||
add_task(function* testProlongedSyncError() {
|
||||
let promiseNotificationAdded = promiseObserver("weave:notification:added");
|
||||
Assert.equal(Notifications.notifications.length, 0, "start with no notifications");
|
||||
|
@ -76,32 +57,6 @@ add_task(function* testProlongedSyncError() {
|
|||
Assert.equal(Notifications.notifications.length, 0, "no notifications left");
|
||||
});
|
||||
|
||||
add_task(function* testProlongedRLError() {
|
||||
Services.prefs.setBoolPref("readinglist.scheduler.enabled", true);
|
||||
let promiseNotificationAdded = promiseObserver("weave:notification:added");
|
||||
Assert.equal(Notifications.notifications.length, 0, "start with no notifications");
|
||||
|
||||
// Pretend the reading-list is in the "prolonged error" state.
|
||||
let longAgo = new Date(Date.now() - 100 * 24 * 60 * 60 * 1000); // 100 days ago.
|
||||
Services.prefs.setCharPref("readinglist.scheduler.lastSync", longAgo.toString());
|
||||
getInternalScheduler().state = ReadingListScheduler.STATE_ERROR_OTHER;
|
||||
Services.obs.notifyObservers(null, "readinglist:sync:start", null);
|
||||
Services.obs.notifyObservers(null, "readinglist:sync:error", null);
|
||||
|
||||
let subject = yield promiseNotificationAdded;
|
||||
let notification = subject.wrappedJSObject.object; // sync's observer abstraction is abstract!
|
||||
Assert.equal(notification.title, stringBundle.GetStringFromName("error.sync.title"));
|
||||
Assert.equal(Notifications.notifications.length, 1, "exactly 1 notification");
|
||||
|
||||
// Now pretend we just had a successful sync - the error notification should go away.
|
||||
let promiseNotificationRemoved = promiseObserver("weave:notification:removed");
|
||||
Services.prefs.setCharPref("readinglist.scheduler.lastSync", Date.now().toString());
|
||||
Services.obs.notifyObservers(null, "readinglist:sync:start", null);
|
||||
Services.obs.notifyObservers(null, "readinglist:sync:finish", null);
|
||||
yield promiseNotificationRemoved;
|
||||
Assert.equal(Notifications.notifications.length, 0, "no notifications left");
|
||||
});
|
||||
|
||||
add_task(function* testSyncLoginError() {
|
||||
let promiseNotificationAdded = promiseObserver("weave:notification:added");
|
||||
Assert.equal(Notifications.notifications.length, 0, "start with no notifications");
|
||||
|
@ -155,13 +110,7 @@ add_task(function* testSyncLoginNetworkError() {
|
|||
Services.obs.notifyObservers(null, "weave:ui:login:error", null);
|
||||
Assert.ok(sawNotificationAdded);
|
||||
|
||||
// clear the notification.
|
||||
let promiseNotificationRemoved = promiseObserver("weave:notification:removed");
|
||||
Services.obs.notifyObservers(null, "readinglist:sync:start", null);
|
||||
Services.obs.notifyObservers(null, "readinglist:sync:finish", null);
|
||||
yield promiseNotificationRemoved;
|
||||
|
||||
// cool - so reset the flag and test what should *not* show an error.
|
||||
// reset the flag and test what should *not* show an error.
|
||||
sawNotificationAdded = false;
|
||||
Weave.Status.sync = Weave.LOGIN_FAILED;
|
||||
Weave.Status.login = Weave.LOGIN_FAILED_NETWORK_ERROR;
|
||||
|
@ -179,80 +128,6 @@ add_task(function* testSyncLoginNetworkError() {
|
|||
}
|
||||
});
|
||||
|
||||
add_task(function* testRLLoginError() {
|
||||
let promiseNotificationAdded = promiseObserver("weave:notification:added");
|
||||
Assert.equal(Notifications.notifications.length, 0, "start with no notifications");
|
||||
|
||||
// Pretend RL is in an auth error state
|
||||
getInternalScheduler().state = ReadingListScheduler.STATE_ERROR_AUTHENTICATION;
|
||||
Services.obs.notifyObservers(null, "readinglist:sync:start", null);
|
||||
Services.obs.notifyObservers(null, "readinglist:sync:error", null);
|
||||
|
||||
let subject = yield promiseNotificationAdded;
|
||||
let notification = subject.wrappedJSObject.object; // sync's observer abstraction is abstract!
|
||||
Assert.equal(notification.title, stringBundle.GetStringFromName("error.login.title"));
|
||||
Assert.equal(Notifications.notifications.length, 1, "exactly 1 notification");
|
||||
|
||||
// Now pretend we just had a successful sync - the error notification should go away.
|
||||
getInternalScheduler().state = ReadingListScheduler.STATE_OK;
|
||||
let promiseNotificationRemoved = promiseObserver("weave:notification:removed");
|
||||
Services.obs.notifyObservers(null, "readinglist:sync:start", null);
|
||||
Services.obs.notifyObservers(null, "readinglist:sync:finish", null);
|
||||
yield promiseNotificationRemoved;
|
||||
Assert.equal(Notifications.notifications.length, 0, "no notifications left");
|
||||
});
|
||||
|
||||
// Here we put readinglist into an "authentication error" state (should see
|
||||
// the error bar reflecting this), then report a prolonged error from Sync (an
|
||||
// infobar to reflect the sync error should replace it), then resolve the sync
|
||||
// error - the authentication error from readinglist should remain.
|
||||
add_task(function* testRLLoginErrorRemains() {
|
||||
let promiseNotificationAdded = promiseObserver("weave:notification:added");
|
||||
Assert.equal(Notifications.notifications.length, 0, "start with no notifications");
|
||||
|
||||
// Pretend RL is in an auth error state
|
||||
getInternalScheduler().state = ReadingListScheduler.STATE_ERROR_AUTHENTICATION;
|
||||
Services.obs.notifyObservers(null, "readinglist:sync:start", null);
|
||||
Services.obs.notifyObservers(null, "readinglist:sync:error", null);
|
||||
|
||||
let subject = yield promiseNotificationAdded;
|
||||
let notification = subject.wrappedJSObject.object; // sync's observer abstraction is abstract!
|
||||
Assert.equal(notification.title, stringBundle.GetStringFromName("error.login.title"));
|
||||
Assert.equal(Notifications.notifications.length, 1, "exactly 1 notification");
|
||||
|
||||
// Now Sync into a prolonged auth error state.
|
||||
promiseNotificationAdded = promiseObserver("weave:notification:added");
|
||||
Weave.Status.sync = Weave.PROLONGED_SYNC_FAILURE;
|
||||
Weave.Status.login = Weave.LOGIN_FAILED_LOGIN_REJECTED;
|
||||
Services.obs.notifyObservers(null, "weave:ui:sync:error", null);
|
||||
subject = yield promiseNotificationAdded;
|
||||
// still exactly 1 notification with the "login" title.
|
||||
notification = subject.wrappedJSObject.object;
|
||||
Assert.equal(notification.title, stringBundle.GetStringFromName("error.login.title"));
|
||||
Assert.equal(Notifications.notifications.length, 1, "exactly 1 notification");
|
||||
|
||||
// Resolve the sync problem.
|
||||
promiseNotificationAdded = promiseObserver("weave:notification:added");
|
||||
Weave.Status.sync = Weave.STATUS_OK;
|
||||
Weave.Status.login = Weave.LOGIN_SUCCEEDED;
|
||||
Services.obs.notifyObservers(null, "weave:ui:sync:finish", null);
|
||||
|
||||
// Expect one notification - the RL login problem.
|
||||
subject = yield promiseNotificationAdded;
|
||||
// still exactly 1 notification with the "login" title.
|
||||
notification = subject.wrappedJSObject.object;
|
||||
Assert.equal(notification.title, stringBundle.GetStringFromName("error.login.title"));
|
||||
Assert.equal(Notifications.notifications.length, 1, "exactly 1 notification");
|
||||
|
||||
// and cleanup - resolve the readinglist error.
|
||||
getInternalScheduler().state = ReadingListScheduler.STATE_OK;
|
||||
let promiseNotificationRemoved = promiseObserver("weave:notification:removed");
|
||||
Services.obs.notifyObservers(null, "readinglist:sync:start", null);
|
||||
Services.obs.notifyObservers(null, "readinglist:sync:finish", null);
|
||||
yield promiseNotificationRemoved;
|
||||
Assert.equal(Notifications.notifications.length, 0, "no notifications left");
|
||||
});
|
||||
|
||||
function checkButtonsStatus(shouldBeActive) {
|
||||
let button = document.getElementById("sync-button");
|
||||
let fxaContainer = document.getElementById("PanelUI-footer-fxa");
|
||||
|
@ -287,26 +162,12 @@ add_task(function* testButtonActivities() {
|
|||
testButtonActions("weave:service:sync:start", "weave:service:sync:finish");
|
||||
testButtonActions("weave:service:sync:start", "weave:service:sync:error");
|
||||
|
||||
testButtonActions("readinglist:sync:start", "readinglist:sync:finish");
|
||||
testButtonActions("readinglist:sync:start", "readinglist:sync:error");
|
||||
|
||||
// and ensure the counters correctly handle multiple in-flight syncs
|
||||
Services.obs.notifyObservers(null, "weave:service:sync:start", null);
|
||||
checkButtonsStatus(true);
|
||||
Services.obs.notifyObservers(null, "readinglist:sync:start", null);
|
||||
checkButtonsStatus(true);
|
||||
Services.obs.notifyObservers(null, "readinglist:sync:finish", null);
|
||||
// sync is still going...
|
||||
checkButtonsStatus(true);
|
||||
// another reading list starts
|
||||
Services.obs.notifyObservers(null, "readinglist:sync:start", null);
|
||||
checkButtonsStatus(true);
|
||||
// The initial sync stops.
|
||||
// sync stops.
|
||||
Services.obs.notifyObservers(null, "weave:service:sync:finish", null);
|
||||
// RL is still going...
|
||||
checkButtonsStatus(true);
|
||||
// RL finishes with an error, so no longer active.
|
||||
Services.obs.notifyObservers(null, "readinglist:sync:error", null);
|
||||
// Button should not be active.
|
||||
checkButtonsStatus(false);
|
||||
} finally {
|
||||
PanelUI.hide();
|
||||
|
|
|
@ -0,0 +1,148 @@
|
|||
/* This test ensures that backspacing autoFilled values still allows to
|
||||
* confirm the remaining value.
|
||||
*/
|
||||
|
||||
function* test_autocomplete(data) {
|
||||
let {desc, typed, autofilled, modified, keys, action, onAutoFill} = data;
|
||||
info(desc);
|
||||
|
||||
yield promiseAutocompleteResultPopup(typed);
|
||||
is(gURLBar.value, autofilled, "autofilled value is as expected");
|
||||
if (onAutoFill)
|
||||
onAutoFill()
|
||||
|
||||
keys.forEach(key => EventUtils.synthesizeKey(key, {}));
|
||||
|
||||
is(gURLBar.value, modified, "backspaced value is as expected");
|
||||
|
||||
yield promiseSearchComplete();
|
||||
|
||||
ok(gURLBar.popup.richlistbox.children.length > 0, "Should get at least 1 result");
|
||||
let result = gURLBar.popup.richlistbox.children[0];
|
||||
let type = result.getAttribute("type");
|
||||
let types = type.split(/\s+/);
|
||||
ok(types.includes(action), `The type attribute "${type}" includes the expected action "${action}"`);
|
||||
|
||||
gURLBar.popup.hidePopup();
|
||||
yield promisePopupHidden(gURLBar.popup);
|
||||
gURLBar.blur();
|
||||
};
|
||||
|
||||
add_task(function* () {
|
||||
registerCleanupFunction(function* () {
|
||||
Services.prefs.clearUserPref("browser.urlbar.unifiedcomplete");
|
||||
Services.prefs.clearUserPref("browser.urlbar.autoFill");
|
||||
gURLBar.handleRevert();
|
||||
yield PlacesTestUtils.clearHistory();
|
||||
});
|
||||
Services.prefs.setBoolPref("browser.urlbar.unifiedcomplete", true);
|
||||
Services.prefs.setBoolPref("browser.urlbar.autoFill", true);
|
||||
|
||||
// Add a typed visit, so it will be autofilled.
|
||||
yield PlacesTestUtils.addVisits({
|
||||
uri: NetUtil.newURI("http://example.com/"),
|
||||
transition: Ci.nsINavHistoryService.TRANSITION_TYPED
|
||||
});
|
||||
|
||||
yield test_autocomplete({ desc: "DELETE the autofilled part should search",
|
||||
typed: "exam",
|
||||
autofilled: "example.com/",
|
||||
modified: "exam",
|
||||
keys: ["VK_DELETE"],
|
||||
action: "searchengine"
|
||||
});
|
||||
yield test_autocomplete({ desc: "DELETE the final slash should visit",
|
||||
typed: "example.com",
|
||||
autofilled: "example.com/",
|
||||
modified: "example.com",
|
||||
keys: ["VK_DELETE"],
|
||||
action: "visiturl"
|
||||
});
|
||||
|
||||
yield test_autocomplete({ desc: "BACK_SPACE the autofilled part should search",
|
||||
typed: "exam",
|
||||
autofilled: "example.com/",
|
||||
modified: "exam",
|
||||
keys: ["VK_BACK_SPACE"],
|
||||
action: "searchengine"
|
||||
});
|
||||
yield test_autocomplete({ desc: "BACK_SPACE the final slash should visit",
|
||||
typed: "example.com",
|
||||
autofilled: "example.com/",
|
||||
modified: "example.com",
|
||||
keys: ["VK_BACK_SPACE"],
|
||||
action: "visiturl"
|
||||
});
|
||||
|
||||
yield test_autocomplete({ desc: "DELETE the autofilled part, then BACK_SPACE, should search",
|
||||
typed: "exam",
|
||||
autofilled: "example.com/",
|
||||
modified: "exa",
|
||||
keys: ["VK_DELETE", "VK_BACK_SPACE"],
|
||||
action: "searchengine"
|
||||
});
|
||||
yield test_autocomplete({ desc: "DELETE the final slash, then BACK_SPACE, should search",
|
||||
typed: "example.com",
|
||||
autofilled: "example.com/",
|
||||
modified: "example.co",
|
||||
keys: ["VK_DELETE", "VK_BACK_SPACE"],
|
||||
action: "visiturl"
|
||||
});
|
||||
|
||||
yield test_autocomplete({ desc: "BACK_SPACE the autofilled part, then BACK_SPACE, should search",
|
||||
typed: "exam",
|
||||
autofilled: "example.com/",
|
||||
modified: "exa",
|
||||
keys: ["VK_BACK_SPACE", "VK_BACK_SPACE"],
|
||||
action: "searchengine"
|
||||
});
|
||||
yield test_autocomplete({ desc: "BACK_SPACE the final slash, then BACK_SPACE, should search",
|
||||
typed: "example.com",
|
||||
autofilled: "example.com/",
|
||||
modified: "example.co",
|
||||
keys: ["VK_BACK_SPACE", "VK_BACK_SPACE"],
|
||||
action: "visiturl"
|
||||
});
|
||||
|
||||
yield test_autocomplete({ desc: "BACK_SPACE after blur should search",
|
||||
typed: "ex",
|
||||
autofilled: "example.com/",
|
||||
modified: "e",
|
||||
keys: ["VK_BACK_SPACE"],
|
||||
action: "searchengine",
|
||||
onAutoFill: () => {
|
||||
gURLBar.blur();
|
||||
gURLBar.focus();
|
||||
gURLBar.selectionStart = 1;
|
||||
gURLBar.selectionEnd = 12;
|
||||
}
|
||||
});
|
||||
yield test_autocomplete({ desc: "DELETE after blur should search",
|
||||
typed: "ex",
|
||||
autofilled: "example.com/",
|
||||
modified: "e",
|
||||
keys: ["VK_DELETE"],
|
||||
action: "searchengine",
|
||||
onAutoFill: () => {
|
||||
gURLBar.blur();
|
||||
gURLBar.focus();
|
||||
gURLBar.selectionStart = 1;
|
||||
gURLBar.selectionEnd = 12;
|
||||
}
|
||||
});
|
||||
yield test_autocomplete({ desc: "double BACK_SPACE after blur should search",
|
||||
typed: "ex",
|
||||
autofilled: "example.com/",
|
||||
modified: "e",
|
||||
keys: ["VK_BACK_SPACE", "VK_BACK_SPACE"],
|
||||
action: "searchengine",
|
||||
onAutoFill: () => {
|
||||
gURLBar.blur();
|
||||
gURLBar.focus();
|
||||
gURLBar.selectionStart = 2;
|
||||
gURLBar.selectionEnd = 12;
|
||||
}
|
||||
});
|
||||
|
||||
yield PlacesTestUtils.clearHistory();
|
||||
});
|
|
@ -96,8 +96,11 @@ let messageHandlers = {
|
|||
type: "mousemove",
|
||||
clickcount: 0,
|
||||
}
|
||||
row.addEventListener("mousemove", function handler() {
|
||||
row.removeEventListener("mousemove", handler);
|
||||
ack("mousemove");
|
||||
});
|
||||
content.synthesizeMouseAtCenter(row, event);
|
||||
ack("mousemove");
|
||||
},
|
||||
|
||||
click: function (arg) {
|
||||
|
@ -124,11 +127,27 @@ let messageHandlers = {
|
|||
ack("addInputValueToFormHistory");
|
||||
},
|
||||
|
||||
addDuplicateOneOff: function () {
|
||||
let btn = gController._oneOffButtons[gController._oneOffButtons.length - 1];
|
||||
let newBtn = btn.cloneNode(true);
|
||||
btn.parentNode.appendChild(newBtn);
|
||||
gController._oneOffButtons.push(newBtn);
|
||||
ack("addDuplicateOneOff");
|
||||
},
|
||||
|
||||
removeLastOneOff: function () {
|
||||
gController._oneOffButtons.pop().remove();
|
||||
ack("removeLastOneOff");
|
||||
},
|
||||
|
||||
reset: function () {
|
||||
// Reset both the input and suggestions by select all + delete.
|
||||
// Reset both the input and suggestions by select all + delete. If there was
|
||||
// no text entered, this won't have any effect, so also escape to ensure the
|
||||
// suggestions table is closed.
|
||||
gController.input.focus();
|
||||
content.synthesizeKey("a", { accelKey: true });
|
||||
content.synthesizeKey("VK_DELETE", {});
|
||||
content.synthesizeKey("VK_ESCAPE", {});
|
||||
ack("reset");
|
||||
},
|
||||
};
|
||||
|
@ -165,6 +184,7 @@ function waitForContentSearchEvent(messageType, cb) {
|
|||
function currentState() {
|
||||
let state = {
|
||||
selectedIndex: gController.selectedIndex,
|
||||
selectedButtonIndex: gController.selectedButtonIndex,
|
||||
numSuggestions: gController._table.hidden ? 0 : gController.numSuggestions,
|
||||
suggestionAtIndex: [],
|
||||
isFormHistorySuggestionAtIndex: [],
|
||||
|
|
|
@ -139,17 +139,6 @@
|
|||
label="&unsortedBookmarksCmd.label;"
|
||||
class="subviewbutton cui-withicon"
|
||||
oncommand="PlacesCommandHook.showPlacesOrganizer('UnfiledBookmarks'); PanelUI.hide();"/>
|
||||
<toolbarseparator>
|
||||
<observes element="readingListSidebar" attribute="hidden"/>
|
||||
</toolbarseparator>
|
||||
<toolbarbutton id="panelMenu_viewReadingListSidebar"
|
||||
label="&readingList.showSidebar.label;"
|
||||
class="subviewbutton"
|
||||
key="key_readingListSidebar"
|
||||
oncommand="SidebarUI.toggle('readingListSidebar'); PanelUI.hide();">
|
||||
<observes element="readingListSidebar" attribute="checked"/>
|
||||
<observes element="readingListSidebar" attribute="hidden"/>
|
||||
</toolbarbutton>
|
||||
<toolbarseparator class="small-separator"/>
|
||||
<toolbaritem id="panelMenu_bookmarksMenu"
|
||||
orient="vertical"
|
||||
|
|
|
@ -0,0 +1,20 @@
|
|||
/* 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";
|
||||
|
||||
Components.utils.import("resource://gre/modules/Extension.jsm");
|
||||
|
||||
let extension;
|
||||
|
||||
function startup(data, reason)
|
||||
{
|
||||
extension = new Extension(data);
|
||||
extension.startup();
|
||||
}
|
||||
|
||||
function shutdown(data, reason)
|
||||
{
|
||||
extension.shutdown();
|
||||
}
|
|
@ -0,0 +1,326 @@
|
|||
XPCOMUtils.defineLazyModuleGetter(this, "CustomizableUI",
|
||||
"resource:///modules/CustomizableUI.jsm");
|
||||
|
||||
Cu.import("resource://gre/modules/devtools/event-emitter.js");
|
||||
|
||||
Cu.import("resource://gre/modules/ExtensionUtils.jsm");
|
||||
let {
|
||||
EventManager,
|
||||
DefaultWeakMap,
|
||||
ignoreEvent,
|
||||
runSafe,
|
||||
} = ExtensionUtils;
|
||||
|
||||
// WeakMap[Extension -> BrowserAction]
|
||||
let browserActionMap = new WeakMap();
|
||||
|
||||
function browserActionOf(extension)
|
||||
{
|
||||
return browserActionMap.get(extension);
|
||||
}
|
||||
|
||||
function makeWidgetId(id)
|
||||
{
|
||||
id = id.toLowerCase();
|
||||
return id.replace(/[^a-z0-9_-]/g, "_");
|
||||
}
|
||||
|
||||
let nextActionId = 0;
|
||||
|
||||
// Responsible for the browser_action section of the manifest as well
|
||||
// as the associated popup.
|
||||
function BrowserAction(options, extension)
|
||||
{
|
||||
this.extension = extension;
|
||||
this.id = makeWidgetId(extension.id) + "-browser-action";
|
||||
this.widget = null;
|
||||
|
||||
this.title = new DefaultWeakMap(extension.localize(options.default_title));
|
||||
this.badgeText = new DefaultWeakMap();
|
||||
this.badgeBackgroundColor = new DefaultWeakMap();
|
||||
this.icon = new DefaultWeakMap(options.default_icon);
|
||||
this.popup = new DefaultWeakMap(options.default_popup);
|
||||
|
||||
// Make the default something that won't compare equal to anything.
|
||||
this.prevPopups = new DefaultWeakMap({});
|
||||
|
||||
this.context = null;
|
||||
}
|
||||
|
||||
BrowserAction.prototype = {
|
||||
build() {
|
||||
let widget = CustomizableUI.createWidget({
|
||||
id: this.id,
|
||||
type: "custom",
|
||||
removable: true,
|
||||
defaultArea: CustomizableUI.AREA_NAVBAR,
|
||||
onBuild: document => {
|
||||
let node = document.createElement("toolbarbutton");
|
||||
node.id = this.id;
|
||||
node.setAttribute("class", "toolbarbutton-1 chromeclass-toolbar-additional badged-button");
|
||||
node.setAttribute("constrain-size", "true");
|
||||
|
||||
this.updateTab(null, node);
|
||||
|
||||
let tabbrowser = document.defaultView.gBrowser;
|
||||
tabbrowser.ownerDocument.addEventListener("TabSelect", () => {
|
||||
this.updateTab(tabbrowser.selectedTab, node);
|
||||
});
|
||||
|
||||
node.addEventListener("command", event => {
|
||||
if (node.getAttribute("type") != "panel") {
|
||||
this.emit("click");
|
||||
}
|
||||
});
|
||||
|
||||
return node;
|
||||
},
|
||||
});
|
||||
this.widget = widget;
|
||||
},
|
||||
|
||||
// Initialize the toolbar icon and popup given that |tab| is the
|
||||
// current tab and |node| is the CustomizableUI node. Note: |tab|
|
||||
// will be null if we don't know the current tab yet (during
|
||||
// initialization).
|
||||
updateTab(tab, node) {
|
||||
let window = node.ownerDocument.defaultView;
|
||||
|
||||
let title = this.getProperty(tab, "title");
|
||||
if (title) {
|
||||
node.setAttribute("tooltiptext", title);
|
||||
node.setAttribute("label", title);
|
||||
} else {
|
||||
node.removeAttribute("tooltiptext");
|
||||
node.removeAttribute("label");
|
||||
}
|
||||
|
||||
let badgeText = this.badgeText.get(tab);
|
||||
if (badgeText) {
|
||||
node.setAttribute("badge", badgeText);
|
||||
} else {
|
||||
node.removeAttribute("badge");
|
||||
}
|
||||
|
||||
function toHex(n) {
|
||||
return Math.floor(n / 16).toString(16) + (n % 16).toString(16);
|
||||
}
|
||||
|
||||
let badgeNode = node.ownerDocument.getAnonymousElementByAttribute(node,
|
||||
'class', 'toolbarbutton-badge');
|
||||
if (badgeNode) {
|
||||
let color = this.badgeBackgroundColor.get(tab);
|
||||
if (Array.isArray(color)) {
|
||||
color = `rgb(${color[0]}, ${color[1]}, ${color[2]})`;
|
||||
}
|
||||
badgeNode.style.backgroundColor = color;
|
||||
}
|
||||
|
||||
let iconURL = this.getIcon(tab, node);
|
||||
node.setAttribute("image", iconURL);
|
||||
|
||||
let popup = this.getProperty(tab, "popup");
|
||||
|
||||
if (popup != this.prevPopups.get(window)) {
|
||||
this.prevPopups.set(window, popup);
|
||||
|
||||
let panel = node.querySelector("panel");
|
||||
if (panel) {
|
||||
panel.remove();
|
||||
}
|
||||
|
||||
if (popup) {
|
||||
let popupURL = this.extension.baseURI.resolve(popup);
|
||||
node.setAttribute("type", "panel");
|
||||
|
||||
let document = node.ownerDocument;
|
||||
let panel = document.createElement("panel");
|
||||
panel.setAttribute("class", "browser-action-panel");
|
||||
panel.setAttribute("type", "arrow");
|
||||
panel.setAttribute("flip", "slide");
|
||||
node.appendChild(panel);
|
||||
|
||||
let browser = document.createElementNS(XUL_NS, "browser");
|
||||
browser.setAttribute("type", "content");
|
||||
browser.setAttribute("disableglobalhistory", "true");
|
||||
browser.setAttribute("width", "500");
|
||||
browser.setAttribute("height", "500");
|
||||
panel.appendChild(browser);
|
||||
|
||||
let loadListener = () => {
|
||||
panel.removeEventListener("load", loadListener);
|
||||
|
||||
if (this.context) {
|
||||
this.context.unload();
|
||||
}
|
||||
|
||||
this.context = new ExtensionPage(this.extension, {
|
||||
type: "popup",
|
||||
contentWindow: browser.contentWindow,
|
||||
uri: Services.io.newURI(popupURL, null, null),
|
||||
docShell: browser.docShell,
|
||||
});
|
||||
GlobalManager.injectInDocShell(browser.docShell, this.extension, this.context);
|
||||
browser.setAttribute("src", popupURL);
|
||||
};
|
||||
panel.addEventListener("load", loadListener);
|
||||
} else {
|
||||
node.removeAttribute("type");
|
||||
}
|
||||
}
|
||||
},
|
||||
|
||||
// Note: tab is allowed to be null here.
|
||||
getIcon(tab, node) {
|
||||
let icon = this.icon.get(tab);
|
||||
|
||||
let url;
|
||||
if (typeof(icon) != "object") {
|
||||
url = icon;
|
||||
} else {
|
||||
let window = node.ownerDocument.defaultView;
|
||||
let utils = window.QueryInterface(Ci.nsIInterfaceRequestor)
|
||||
.getInterface(Components.interfaces.nsIDOMWindowUtils);
|
||||
let res = {value: 1}
|
||||
utils.getResolution(res);
|
||||
|
||||
let size = res.value == 1 ? 19 : 38;
|
||||
url = icon[size];
|
||||
}
|
||||
|
||||
if (url) {
|
||||
return this.extension.baseURI.resolve(url);
|
||||
} else {
|
||||
return "chrome://browser/content/extension.svg";
|
||||
}
|
||||
},
|
||||
|
||||
// Update the toolbar button for a given window.
|
||||
updateWindow(window) {
|
||||
let tab = window.gBrowser ? window.gBrowser.selectedTab : null;
|
||||
let node = CustomizableUI.getWidget(this.id).forWindow(window).node;
|
||||
this.updateTab(tab, node);
|
||||
},
|
||||
|
||||
// Update the toolbar button when the extension changes the icon,
|
||||
// title, badge, etc. If it only changes a parameter for a single
|
||||
// tab, |tab| will be that tab. Otherwise it will be null.
|
||||
updateOnChange(tab) {
|
||||
if (tab) {
|
||||
if (tab.selected) {
|
||||
this.updateWindow(tab.ownerDocument.defaultView);
|
||||
}
|
||||
} else {
|
||||
let e = Services.wm.getEnumerator("navigator:browser");
|
||||
while (e.hasMoreElements()) {
|
||||
let window = e.getNext();
|
||||
if (window.gBrowser) {
|
||||
this.updateWindow(window);
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
|
||||
// tab is allowed to be null.
|
||||
// prop should be one of "icon", "title", "badgeText", "popup", or "badgeBackgroundColor".
|
||||
setProperty(tab, prop, value) {
|
||||
this[prop].set(tab, value);
|
||||
this.updateOnChange(tab);
|
||||
},
|
||||
|
||||
// tab is allowed to be null.
|
||||
// prop should be one of "title", "badgeText", "popup", or "badgeBackgroundColor".
|
||||
getProperty(tab, prop) {
|
||||
return this[prop].get(tab);
|
||||
},
|
||||
|
||||
shutdown() {
|
||||
CustomizableUI.destroyWidget(this.id);
|
||||
},
|
||||
};
|
||||
|
||||
EventEmitter.decorate(BrowserAction.prototype);
|
||||
|
||||
extensions.on("manifest_browser_action", (type, directive, extension, manifest) => {
|
||||
let browserAction = new BrowserAction(manifest.browser_action, extension);
|
||||
browserAction.build();
|
||||
browserActionMap.set(extension, browserAction);
|
||||
});
|
||||
|
||||
extensions.on("shutdown", (type, extension) => {
|
||||
if (browserActionMap.has(extension)) {
|
||||
browserActionMap.get(extension).shutdown();
|
||||
browserActionMap.delete(extension);
|
||||
}
|
||||
});
|
||||
|
||||
extensions.registerAPI((extension, context) => {
|
||||
return {
|
||||
browserAction: {
|
||||
onClicked: new EventManager(context, "browserAction.onClicked", fire => {
|
||||
let listener = () => {
|
||||
let tab = TabManager.activeTab;
|
||||
fire(TabManager.convert(extension, tab));
|
||||
};
|
||||
browserActionOf(extension).on("click", listener);
|
||||
return () => {
|
||||
browserActionOf(extension).off("click", listener);
|
||||
};
|
||||
}).api(),
|
||||
|
||||
setTitle: function(details) {
|
||||
let tab = details.tabId ? TabManager.getTab(details.tabId) : null;
|
||||
browserActionOf(extension).setProperty(tab, "title", details.title);
|
||||
},
|
||||
|
||||
getTitle: function(details, callback) {
|
||||
let tab = details.tabId ? TabManager.getTab(details.tabId) : null;
|
||||
let title = browserActionOf(extension).getProperty(tab, "title");
|
||||
runSafe(context, callback, title);
|
||||
},
|
||||
|
||||
setIcon: function(details, callback) {
|
||||
let tab = details.tabId ? TabManager.getTab(details.tabId) : null;
|
||||
if (details.imageData) {
|
||||
// FIXME: Support the imageData attribute.
|
||||
return;
|
||||
}
|
||||
browserActionOf(extension).setProperty(tab, "icon", details.path);
|
||||
},
|
||||
|
||||
setBadgeText: function(details) {
|
||||
let tab = details.tabId ? TabManager.getTab(details.tabId) : null;
|
||||
browserActionOf(extension).setProperty(tab, "badgeText", details.text);
|
||||
},
|
||||
|
||||
getBadgeText: function(details, callback) {
|
||||
let tab = details.tabId ? TabManager.getTab(details.tabId) : null;
|
||||
let text = browserActionOf(extension).getProperty(tab, "badgeText");
|
||||
runSafe(context, callback, text);
|
||||
},
|
||||
|
||||
setPopup: function(details) {
|
||||
let tab = details.tabId ? TabManager.getTab(details.tabId) : null;
|
||||
browserActionOf(extension).setProperty(tab, "popup", details.popup);
|
||||
},
|
||||
|
||||
getPopup: function(details, callback) {
|
||||
let tab = details.tabId ? TabManager.getTab(details.tabId) : null;
|
||||
let popup = browserActionOf(extension).getProperty(tab, "popup");
|
||||
runSafe(context, callback, popup);
|
||||
},
|
||||
|
||||
setBadgeBackgroundColor: function(details) {
|
||||
let color = details.color;
|
||||
let tab = details.tabId ? TabManager.getTab(details.tabId) : null;
|
||||
browserActionOf(extension).setProperty(tab, "badgeBackgroundColor", details.color);
|
||||
},
|
||||
|
||||
getBadgeBackgroundColor: function(details, callback) {
|
||||
let tab = details.tabId ? TabManager.getTab(details.tabId) : null;
|
||||
let color = browserActionOf(extension).getProperty(tab, "badgeBackgroundColor");
|
||||
runSafe(context, callback, color);
|
||||
},
|
||||
}
|
||||
};
|
||||
});
|
|
@ -0,0 +1,8 @@
|
|||
extensions.registerPrivilegedAPI("contextMenus", (extension, context) => {
|
||||
return {
|
||||
contextMenus: {
|
||||
create() {},
|
||||
removeAll() {},
|
||||
},
|
||||
};
|
||||
});
|
|
@ -0,0 +1,486 @@
|
|||
XPCOMUtils.defineLazyModuleGetter(this, "NewTabURL",
|
||||
"resource:///modules/NewTabURL.jsm");
|
||||
|
||||
Cu.import("resource://gre/modules/ExtensionUtils.jsm");
|
||||
let {
|
||||
EventManager,
|
||||
ignoreEvent,
|
||||
runSafe,
|
||||
} = ExtensionUtils;
|
||||
|
||||
// This function is pretty tightly tied to Extension.jsm.
|
||||
// Its job is to fill in the |tab| property of the sender.
|
||||
function getSender(context, target, sender)
|
||||
{
|
||||
// The message was sent from a content script to a <browser> element.
|
||||
// We can just get the |tab| from |target|.
|
||||
if (target instanceof Ci.nsIDOMXULElement) {
|
||||
// The message came from a content script.
|
||||
let tabbrowser = target.ownerDocument.defaultView.gBrowser;
|
||||
if (!tabbrowser) {
|
||||
return;
|
||||
}
|
||||
let tab = tabbrowser.getTabForBrowser(target);
|
||||
|
||||
sender.tab = TabManager.convert(context.extension, tab);
|
||||
} else {
|
||||
// The message came from an ExtensionPage. In that case, it should
|
||||
// include a tabId property (which is filled in by the page-open
|
||||
// listener below).
|
||||
if ("tabId" in sender) {
|
||||
sender.tab = TabManager.convert(context.extension, TabManager.getTab(sender.tabId));
|
||||
delete sender.tabId;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// WeakMap[ExtensionPage -> {tab, parentWindow}]
|
||||
let pageDataMap = new WeakMap();
|
||||
|
||||
// This listener fires whenever an extension page opens in a tab
|
||||
// (either initiated by the extension or the user). Its job is to fill
|
||||
// in some tab-specific details and keep data around about the
|
||||
// ExtensionPage.
|
||||
extensions.on("page-load", (type, page, params, sender, delegate) => {
|
||||
if (params.type == "tab") {
|
||||
let browser = params.docShell.chromeEventHandler;
|
||||
let parentWindow = browser.ownerDocument.defaultView;
|
||||
let tab = parentWindow.gBrowser.getTabForBrowser(browser);
|
||||
sender.tabId = TabManager.getId(tab);
|
||||
|
||||
pageDataMap.set(page, {tab, parentWindow});
|
||||
}
|
||||
|
||||
delegate.getSender = getSender;
|
||||
});
|
||||
|
||||
extensions.on("page-unload", (type, page) => {
|
||||
pageDataMap.delete(page);
|
||||
});
|
||||
|
||||
extensions.on("page-shutdown", (type, page) => {
|
||||
if (pageDataMap.has(page)) {
|
||||
let {tab, parentWindow} = pageDataMap.get(page);
|
||||
pageDataMap.delete(page);
|
||||
|
||||
parentWindow.gBrowser.removeTab(tab);
|
||||
}
|
||||
});
|
||||
|
||||
extensions.on("fill-browser-data", (type, browser, data, result) => {
|
||||
let tabId = TabManager.getBrowserId(browser);
|
||||
if (tabId == -1) {
|
||||
result.cancel = true;
|
||||
return;
|
||||
}
|
||||
|
||||
data.tabId = tabId;
|
||||
});
|
||||
|
||||
// TODO: activeTab permission
|
||||
|
||||
extensions.registerAPI((extension, context) => {
|
||||
let self = {
|
||||
tabs: {
|
||||
onActivated: new WindowEventManager(context, "tabs.onActivated", "TabSelect", (fire, event) => {
|
||||
let tab = event.originalTarget;
|
||||
let tabId = TabManager.getId(tab);
|
||||
let windowId = WindowManager.getId(tab.ownerDocument.defaultView);
|
||||
fire({tabId, windowId});
|
||||
}).api(),
|
||||
|
||||
onCreated: new EventManager(context, "tabs.onCreated", fire => {
|
||||
let listener = event => {
|
||||
let tab = event.originalTarget;
|
||||
fire({tab: TabManager.convert(extension, tab)});
|
||||
};
|
||||
|
||||
let windowListener = window => {
|
||||
for (let tab of window.gBrowser.tabs) {
|
||||
fire({tab: TabManager.convert(extension, tab)});
|
||||
}
|
||||
};
|
||||
|
||||
WindowListManager.addOpenListener(windowListener, false);
|
||||
AllWindowEvents.addListener("TabOpen", listener);
|
||||
return () => {
|
||||
WindowListManager.removeOpenListener(windowListener);
|
||||
AllWindowEvents.removeListener("TabOpen", listener);
|
||||
};
|
||||
}).api(),
|
||||
|
||||
onUpdated: new EventManager(context, "tabs.onUpdated", fire => {
|
||||
function sanitize(extension, changeInfo) {
|
||||
let result = {};
|
||||
let nonempty = false;
|
||||
for (let prop in changeInfo) {
|
||||
if ((prop != "favIconUrl" && prop != "url") || extension.hasPermission("tabs")) {
|
||||
nonempty = true;
|
||||
result[prop] = changeInfo[prop];
|
||||
}
|
||||
}
|
||||
return [nonempty, result];
|
||||
}
|
||||
|
||||
let listener = event => {
|
||||
let tab = event.originalTarget;
|
||||
let window = tab.ownerDocument.defaultView;
|
||||
let tabId = TabManager.getId(tab);
|
||||
|
||||
let changeInfo = {};
|
||||
let needed = false;
|
||||
if (event.type == "TabAttrModified") {
|
||||
if (event.detail.changed.indexOf("image") != -1) {
|
||||
changeInfo.favIconUrl = window.gBrowser.getIcon(tab);
|
||||
needed = true;
|
||||
}
|
||||
} else if (event.type == "TabPinned") {
|
||||
changeInfo.pinned = true;
|
||||
needed = true;
|
||||
} else if (event.type == "TabUnpinned") {
|
||||
changeInfo.pinned = false;
|
||||
needed = true;
|
||||
}
|
||||
|
||||
[needed, changeInfo] = sanitize(extension, changeInfo);
|
||||
if (needed) {
|
||||
fire(tabId, changeInfo, TabManager.convert(extension, tab));
|
||||
}
|
||||
};
|
||||
let progressListener = {
|
||||
onStateChange(browser, webProgress, request, stateFlags, statusCode) {
|
||||
if (!webProgress.isTopLevel) {
|
||||
return;
|
||||
}
|
||||
|
||||
let status;
|
||||
if (stateFlags & Ci.nsIWebProgressListener.STATE_IS_WINDOW) {
|
||||
if (stateFlags & Ci.nsIWebProgressListener.STATE_START) {
|
||||
status = "loading";
|
||||
} else if (stateFlags & Ci.nsIWebProgressListener.STATE_STOP) {
|
||||
status = "complete";
|
||||
}
|
||||
} else if (stateFlags & Ci.nsIWebProgressListener.STATE_STOP &&
|
||||
statusCode == Cr.NS_BINDING_ABORTED) {
|
||||
status = "complete";
|
||||
}
|
||||
|
||||
let gBrowser = browser.ownerDocument.defaultView.gBrowser;
|
||||
let tab = gBrowser.getTabForBrowser(browser);
|
||||
let tabId = TabManager.getId(tab);
|
||||
let [needed, changeInfo] = sanitize(extension, {status});
|
||||
fire(tabId, changeInfo, TabManager.convert(extension, tab));
|
||||
},
|
||||
|
||||
onLocationChange(browser, webProgress, request, locationURI, flags) {
|
||||
let gBrowser = browser.ownerDocument.defaultView.gBrowser;
|
||||
let tab = gBrowser.getTabForBrowser(browser);
|
||||
let tabId = TabManager.getId(tab);
|
||||
let [needed, changeInfo] = sanitize(extension, {url: locationURI.spec});
|
||||
if (needed) {
|
||||
fire(tabId, changeInfo, TabManager.convert(extension, tab));
|
||||
}
|
||||
},
|
||||
};
|
||||
|
||||
AllWindowEvents.addListener("progress", progressListener);
|
||||
AllWindowEvents.addListener("TabAttrModified", listener);
|
||||
AllWindowEvents.addListener("TabPinned", listener);
|
||||
AllWindowEvents.addListener("TabUnpinned", listener);
|
||||
return () => {
|
||||
AllWindowEvents.removeListener("progress", progressListener);
|
||||
AllWindowEvents.addListener("TabAttrModified", listener);
|
||||
AllWindowEvents.addListener("TabPinned", listener);
|
||||
AllWindowEvents.addListener("TabUnpinned", listener);
|
||||
};
|
||||
}).api(),
|
||||
|
||||
onReplaced: ignoreEvent(),
|
||||
|
||||
onRemoved: new EventManager(context, "tabs.onRemoved", fire => {
|
||||
let tabListener = event => {
|
||||
let tab = event.originalTarget;
|
||||
let tabId = TabManager.getId(tab);
|
||||
let windowId = WindowManager.getId(tab.ownerDocument.defaultView);
|
||||
let removeInfo = {windowId, isWindowClosing: false};
|
||||
fire(tabId, removeInfo);
|
||||
};
|
||||
|
||||
let windowListener = window => {
|
||||
for (let tab of window.gBrowser.tabs) {
|
||||
let tabId = TabManager.getId(tab);
|
||||
let windowId = WindowManager.getId(window);
|
||||
let removeInfo = {windowId, isWindowClosing: true};
|
||||
fire(tabId, removeInfo);
|
||||
}
|
||||
};
|
||||
|
||||
WindowListManager.addCloseListener(windowListener);
|
||||
AllWindowEvents.addListener("TabClose", tabListener);
|
||||
return () => {
|
||||
WindowListManager.removeCloseListener(windowListener);
|
||||
AllWindowEvents.removeListener("TabClose", tabListener);
|
||||
};
|
||||
}).api(),
|
||||
|
||||
create: function(createProperties, callback) {
|
||||
if (!createProperties) {
|
||||
createProperties = {};
|
||||
}
|
||||
|
||||
let url = createProperties.url || NewTabURL.get();
|
||||
url = extension.baseURI.resolve(url);
|
||||
|
||||
function createInWindow(window) {
|
||||
let tab = window.gBrowser.addTab(url);
|
||||
|
||||
let active = true;
|
||||
if ("active" in createProperties) {
|
||||
active = createProperties.active;
|
||||
} else if ("selected" in createProperties) {
|
||||
active = createProperties.selected;
|
||||
}
|
||||
if (active) {
|
||||
window.gBrowser.selectedTab = tab;
|
||||
}
|
||||
|
||||
if ("index" in createProperties) {
|
||||
window.gBrowser.moveTabTo(tab, createProperties.index);
|
||||
}
|
||||
|
||||
if (createProperties.pinned) {
|
||||
window.gBrowser.pinTab(tab);
|
||||
}
|
||||
|
||||
if (callback) {
|
||||
runSafe(context, callback, TabManager.convert(extension, tab));
|
||||
}
|
||||
}
|
||||
|
||||
let window = createProperties.windowId ?
|
||||
WindowManager.getWindow(createProperties.windowId) :
|
||||
WindowManager.topWindow;
|
||||
if (!window.gBrowser) {
|
||||
let obs = (finishedWindow, topic, data) => {
|
||||
if (finishedWindow != window) {
|
||||
return;
|
||||
}
|
||||
Services.obs.removeObserver(obs, "browser-delayed-startup-finished");
|
||||
createInWindow(window);
|
||||
};
|
||||
Services.obs.addObserver(obs, "browser-delayed-startup-finished", false);
|
||||
} else {
|
||||
createInWindow(window);
|
||||
}
|
||||
},
|
||||
|
||||
remove: function(tabs, callback) {
|
||||
if (!Array.isArray(tabs)) {
|
||||
tabs = [tabs];
|
||||
}
|
||||
|
||||
for (let tabId of tabs) {
|
||||
let tab = TabManager.getTab(tabId);
|
||||
tab.ownerDocument.defaultView.gBrowser.removeTab(tab);
|
||||
}
|
||||
|
||||
if (callback) {
|
||||
runSafe(context, callback);
|
||||
}
|
||||
},
|
||||
|
||||
update: function(...args) {
|
||||
let tabId, updateProperties, callback;
|
||||
if (args.length == 1) {
|
||||
updateProperties = args[0];
|
||||
} else {
|
||||
[tabId, updateProperties, callback] = args;
|
||||
}
|
||||
|
||||
let tab = tabId ? TabManager.getTab(tabId) : TabManager.activeTab;
|
||||
let tabbrowser = tab.ownerDocument.gBrowser;
|
||||
if ("url" in updateProperties) {
|
||||
tab.linkedBrowser.loadURI(updateProperties.url);
|
||||
}
|
||||
if ("active" in updateProperties) {
|
||||
if (updateProperties.active) {
|
||||
tabbrowser.selectedTab = tab;
|
||||
} else {
|
||||
// Not sure what to do here? Which tab should we select?
|
||||
}
|
||||
}
|
||||
if ("pinned" in updateProperties) {
|
||||
if (updateProperties.pinned) {
|
||||
tabbrowser.pinTab(tab);
|
||||
} else {
|
||||
tabbrowser.unpinTab(tab);
|
||||
}
|
||||
}
|
||||
// FIXME: highlighted/selected, openerTabId
|
||||
|
||||
if (callback) {
|
||||
runSafe(context, callback, TabManager.convert(extension, tab));
|
||||
}
|
||||
},
|
||||
|
||||
reload: function(tabId, reloadProperties, callback) {
|
||||
let tab = tabId ? TabManager.getTab(tabId) : TabManager.activeTab;
|
||||
let flags = Ci.nsIWebNavigation.LOAD_FLAGS_NONE;
|
||||
if (reloadProperties && reloadProperties.bypassCache) {
|
||||
flags |= Ci.nsIWebNavigation.LOAD_FLAGS_BYPASS_CACHE;
|
||||
}
|
||||
tab.linkedBrowser.reloadWithFlags(flags);
|
||||
|
||||
if (callback) {
|
||||
runSafe(context, callback);
|
||||
}
|
||||
},
|
||||
|
||||
get: function(tabId, callback) {
|
||||
let tab = TabManager.getTab(tabId);
|
||||
runSafe(context, callback, TabManager.convert(extension, tab));
|
||||
},
|
||||
|
||||
getAllInWindow: function(...args) {
|
||||
let window, callback;
|
||||
if (args.length == 1) {
|
||||
callbacks = args[0];
|
||||
} else {
|
||||
window = WindowManager.getWindow(args[0]);
|
||||
callback = args[1];
|
||||
}
|
||||
|
||||
if (!window) {
|
||||
window = WindowManager.topWindow;
|
||||
}
|
||||
|
||||
return self.tabs.query({windowId: WindowManager.getId(window)}, callback);
|
||||
},
|
||||
|
||||
query: function(queryInfo, callback) {
|
||||
if (!queryInfo) {
|
||||
queryInfo = {};
|
||||
}
|
||||
|
||||
function matches(window, tab) {
|
||||
let props = ["active", "pinned", "highlighted", "status", "title", "url", "index"];
|
||||
for (let prop of props) {
|
||||
if (prop in queryInfo && queryInfo[prop] != tab[prop]) {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
let lastFocused = window == WindowManager.topWindow;
|
||||
if ("lastFocusedWindow" in queryInfo && queryInfo.lastFocusedWindow != lastFocused) {
|
||||
return false;
|
||||
}
|
||||
|
||||
let windowType = WindowManager.windowType(window);
|
||||
if ("windowType" in queryInfo && queryInfo.windowType != windowType) {
|
||||
return false;
|
||||
}
|
||||
|
||||
if ("windowId" in queryInfo) {
|
||||
if (queryInfo.windowId == WindowManager.WINDOW_ID_CURRENT) {
|
||||
if (context.contentWindow != window) {
|
||||
return false;
|
||||
}
|
||||
} else {
|
||||
if (queryInfo.windowId != tab.windowId) {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if ("currentWindow" in queryInfo) {
|
||||
let eq = window == context.contentWindow;
|
||||
if (queryInfo.currentWindow != eq) {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
return true;
|
||||
}
|
||||
|
||||
let result = [];
|
||||
let e = Services.wm.getEnumerator("navigator:browser");
|
||||
while (e.hasMoreElements()) {
|
||||
let window = e.getNext();
|
||||
let tabs = TabManager.getTabs(extension, window);
|
||||
for (let tab of tabs) {
|
||||
if (matches(window, tab)) {
|
||||
result.push(tab);
|
||||
}
|
||||
}
|
||||
}
|
||||
runSafe(context, callback, result);
|
||||
},
|
||||
|
||||
_execute: function(tabId, details, kind, callback) {
|
||||
let tab = tabId ? TabManager.getTab(tabId) : TabManager.activeTab;
|
||||
let mm = tab.linkedBrowser.messageManager;
|
||||
|
||||
let options = {js: [], css: []};
|
||||
if (details.code) {
|
||||
options[kind + 'Code'] = details.code;
|
||||
}
|
||||
if (details.file) {
|
||||
options[kind].push(extension.baseURI.resolve(details.file));
|
||||
}
|
||||
if (details.allFrames) {
|
||||
options.all_frames = details.allFrames;
|
||||
}
|
||||
if (details.matchAboutBlank) {
|
||||
options.match_about_blank = details.matchAboutBlank;
|
||||
}
|
||||
if (details.runAt) {
|
||||
options.run_at = details.runAt;
|
||||
}
|
||||
mm.sendAsyncMessage("Extension:Execute",
|
||||
{extensionId: extension.id, options});
|
||||
|
||||
// TODO: Call the callback with the result (which is what???).
|
||||
},
|
||||
|
||||
executeScript: function(...args) {
|
||||
if (args.length == 1) {
|
||||
self.tabs._execute(undefined, args[0], 'js', undefined);
|
||||
} else {
|
||||
self.tabs._execute(args[0], args[1], 'js', args[2]);
|
||||
}
|
||||
},
|
||||
|
||||
insertCss: function(tabId, details, callback) {
|
||||
if (args.length == 1) {
|
||||
self.tabs._execute(undefined, args[0], 'css', undefined);
|
||||
} else {
|
||||
self.tabs._execute(args[0], args[1], 'css', args[2]);
|
||||
}
|
||||
},
|
||||
|
||||
connect: function(tabId, connectInfo) {
|
||||
let tab = TabManager.getTab(tabId);
|
||||
let mm = tab.linkedBrowser.messageManager;
|
||||
|
||||
let name = connectInfo.name || "";
|
||||
let recipient = {extensionId: extension.id};
|
||||
if ("frameId" in connectInfo) {
|
||||
recipient.frameId = connectInfo.frameId;
|
||||
}
|
||||
return context.messenger.connect(mm, name, recipient);
|
||||
},
|
||||
|
||||
sendMessage: function(tabId, message, options, responseCallback) {
|
||||
let tab = TabManager.getTab(tabId);
|
||||
let mm = tab.linkedBrowser.messageManager;
|
||||
|
||||
let recipient = {extensionId: extension.id};
|
||||
if (options && "frameId" in options) {
|
||||
recipient.frameId = options.frameId;
|
||||
}
|
||||
return context.messenger.sendMessage(mm, message, recipient, responseCallback);
|
||||
},
|
||||
},
|
||||
};
|
||||
return self;
|
||||
});
|
|
@ -0,0 +1,324 @@
|
|||
XPCOMUtils.defineLazyModuleGetter(this, "PrivateBrowsingUtils",
|
||||
"resource://gre/modules/PrivateBrowsingUtils.jsm");
|
||||
|
||||
Cu.import("resource://gre/modules/ExtensionUtils.jsm");
|
||||
let {
|
||||
EventManager,
|
||||
} = ExtensionUtils;
|
||||
|
||||
// This file provides some useful code for the |tabs| and |windows|
|
||||
// modules. All of the code is installed on |global|, which is a scope
|
||||
// shared among the different ext-*.js scripts.
|
||||
|
||||
// Manages mapping between XUL tabs and extension tab IDs.
|
||||
global.TabManager = {
|
||||
_tabs: new WeakMap(),
|
||||
_nextId: 1,
|
||||
|
||||
getId(tab) {
|
||||
if (this._tabs.has(tab)) {
|
||||
return this._tabs.get(tab);
|
||||
}
|
||||
let id = this._nextId++;
|
||||
this._tabs.set(tab, id);
|
||||
return id;
|
||||
},
|
||||
|
||||
getBrowserId(browser) {
|
||||
let gBrowser = browser.ownerDocument.defaultView.gBrowser;
|
||||
// Some non-browser windows have gBrowser but not
|
||||
// getTabForBrowser!
|
||||
if (gBrowser && gBrowser.getTabForBrowser) {
|
||||
let tab = gBrowser.getTabForBrowser(browser);
|
||||
if (tab) {
|
||||
return this.getId(tab);
|
||||
}
|
||||
}
|
||||
return -1;
|
||||
},
|
||||
|
||||
getTab(tabId) {
|
||||
// FIXME: Speed this up without leaking memory somehow.
|
||||
let e = Services.wm.getEnumerator("navigator:browser");
|
||||
while (e.hasMoreElements()) {
|
||||
let window = e.getNext();
|
||||
if (!window.gBrowser) {
|
||||
continue;
|
||||
}
|
||||
for (let tab of window.gBrowser.tabs) {
|
||||
if (this.getId(tab) == tabId) {
|
||||
return tab;
|
||||
}
|
||||
}
|
||||
}
|
||||
return null;
|
||||
},
|
||||
|
||||
get activeTab() {
|
||||
let window = WindowManager.topWindow;
|
||||
if (window && window.gBrowser) {
|
||||
return window.gBrowser.selectedTab;
|
||||
}
|
||||
return null;
|
||||
},
|
||||
|
||||
getStatus(tab) {
|
||||
return tab.getAttribute("busy") == "true" ? "loading" : "complete";
|
||||
},
|
||||
|
||||
convert(extension, tab) {
|
||||
let window = tab.ownerDocument.defaultView;
|
||||
let windowActive = window == WindowManager.topWindow;
|
||||
let result = {
|
||||
id: this.getId(tab),
|
||||
index: tab._tPos,
|
||||
windowId: WindowManager.getId(window),
|
||||
selected: tab.selected,
|
||||
highlighted: tab.selected,
|
||||
active: tab.selected,
|
||||
pinned: tab.pinned,
|
||||
status: this.getStatus(tab),
|
||||
incognito: PrivateBrowsingUtils.isBrowserPrivate(tab.linkedBrowser),
|
||||
width: tab.linkedBrowser.clientWidth,
|
||||
height: tab.linkedBrowser.clientHeight,
|
||||
};
|
||||
|
||||
if (extension.hasPermission("tabs")) {
|
||||
result.url = tab.linkedBrowser.currentURI.spec;
|
||||
if (tab.linkedBrowser.contentTitle) {
|
||||
result.title = tab.linkedBrowser.contentTitle;
|
||||
}
|
||||
let icon = window.gBrowser.getIcon(tab);
|
||||
if (icon) {
|
||||
result.favIconUrl = icon;
|
||||
}
|
||||
}
|
||||
|
||||
return result;
|
||||
},
|
||||
|
||||
getTabs(extension, window) {
|
||||
if (!window.gBrowser) {
|
||||
return [];
|
||||
}
|
||||
return [ for (tab of window.gBrowser.tabs) this.convert(extension, tab) ];
|
||||
},
|
||||
};
|
||||
|
||||
// Manages mapping between XUL windows and extension window IDs.
|
||||
global.WindowManager = {
|
||||
_windows: new WeakMap(),
|
||||
_nextId: 0,
|
||||
|
||||
WINDOW_ID_NONE: -1,
|
||||
WINDOW_ID_CURRENT: -2,
|
||||
|
||||
get topWindow() {
|
||||
return Services.wm.getMostRecentWindow("navigator:browser");
|
||||
},
|
||||
|
||||
windowType(window) {
|
||||
// TODO: Make this work.
|
||||
return "normal";
|
||||
},
|
||||
|
||||
getId(window) {
|
||||
if (this._windows.has(window)) {
|
||||
return this._windows.get(window);
|
||||
}
|
||||
let id = this._nextId++;
|
||||
this._windows.set(window, id);
|
||||
return id;
|
||||
},
|
||||
|
||||
getWindow(id) {
|
||||
let e = Services.wm.getEnumerator("navigator:browser");
|
||||
while (e.hasMoreElements()) {
|
||||
let window = e.getNext();
|
||||
if (this.getId(window) == id) {
|
||||
return window;
|
||||
}
|
||||
}
|
||||
return null;
|
||||
},
|
||||
|
||||
convert(extension, window, getInfo) {
|
||||
let result = {
|
||||
id: this.getId(window),
|
||||
focused: window == WindowManager.topWindow,
|
||||
top: window.screenY,
|
||||
left: window.screenX,
|
||||
width: window.outerWidth,
|
||||
height: window.outerHeight,
|
||||
incognito: PrivateBrowsingUtils.isWindowPrivate(window),
|
||||
|
||||
// We fudge on these next two.
|
||||
type: this.windowType(window),
|
||||
state: window.fullScreen ? "fullscreen" : "normal",
|
||||
};
|
||||
|
||||
if (getInfo && getInfo.populate) {
|
||||
results.tabs = TabManager.getTabs(extension, window);
|
||||
}
|
||||
|
||||
return result;
|
||||
},
|
||||
};
|
||||
|
||||
// Manages listeners for window opening and closing. A window is
|
||||
// considered open when the "load" event fires on it. A window is
|
||||
// closed when a "domwindowclosed" notification fires for it.
|
||||
global.WindowListManager = {
|
||||
_openListeners: new Set(),
|
||||
_closeListeners: new Set(),
|
||||
|
||||
addOpenListener(listener, fireOnExisting = true) {
|
||||
if (this._openListeners.length == 0 && this._closeListeners.length == 0) {
|
||||
Services.ww.registerNotification(this);
|
||||
}
|
||||
this._openListeners.add(listener);
|
||||
|
||||
let e = Services.wm.getEnumerator("navigator:browser");
|
||||
while (e.hasMoreElements()) {
|
||||
let window = e.getNext();
|
||||
if (window.document.readyState != "complete") {
|
||||
window.addEventListener("load", this);
|
||||
} else if (fireOnExisting) {
|
||||
listener(window);
|
||||
}
|
||||
}
|
||||
},
|
||||
|
||||
removeOpenListener(listener) {
|
||||
this._openListeners.delete(listener);
|
||||
if (this._openListeners.length == 0 && this._closeListeners.length == 0) {
|
||||
Services.ww.unregisterNotification(this);
|
||||
}
|
||||
},
|
||||
|
||||
addCloseListener(listener) {
|
||||
if (this._openListeners.length == 0 && this._closeListeners.length == 0) {
|
||||
Services.ww.registerNotification(this);
|
||||
}
|
||||
this._closeListeners.add(listener);
|
||||
},
|
||||
|
||||
removeCloseListener(listener) {
|
||||
this._closeListeners.delete(listener);
|
||||
if (this._openListeners.length == 0 && this._closeListeners.length == 0) {
|
||||
Services.ww.unregisterNotification(this);
|
||||
}
|
||||
},
|
||||
|
||||
handleEvent(event) {
|
||||
let window = event.target.defaultView;
|
||||
window.removeEventListener("load", this.loadListener);
|
||||
if (window.document.documentElement.getAttribute("windowtype") != "navigator:browser") {
|
||||
return;
|
||||
}
|
||||
|
||||
for (let listener of this._openListeners) {
|
||||
listener(window);
|
||||
}
|
||||
},
|
||||
|
||||
queryInterface: XPCOMUtils.generateQI([Ci.nsISupports, Ci.nsIObserver]),
|
||||
|
||||
observe(window, topic, data) {
|
||||
if (topic == "domwindowclosed") {
|
||||
if (window.document.documentElement.getAttribute("windowtype") != "navigator:browser") {
|
||||
return;
|
||||
}
|
||||
|
||||
window.removeEventListener("load", this);
|
||||
for (let listener of this._closeListeners) {
|
||||
listener(window);
|
||||
}
|
||||
} else {
|
||||
window.addEventListener("load", this);
|
||||
}
|
||||
},
|
||||
};
|
||||
|
||||
// Provides a facility to listen for DOM events across all XUL windows.
|
||||
global.AllWindowEvents = {
|
||||
_listeners: new Map(),
|
||||
|
||||
// If |type| is a normal event type, invoke |listener| each time
|
||||
// that event fires in any open window. If |type| is "progress", add
|
||||
// a web progress listener that covers all open windows.
|
||||
addListener(type, listener) {
|
||||
if (type == "domwindowopened") {
|
||||
return WindowListManager.addOpenListener(listener);
|
||||
} else if (type == "domwindowclosed") {
|
||||
return WindowListManager.addCloseListener(listener);
|
||||
}
|
||||
|
||||
let needOpenListener = this._listeners.size == 0;
|
||||
|
||||
if (!this._listeners.has(type)) {
|
||||
this._listeners.set(type, new Set());
|
||||
}
|
||||
let list = this._listeners.get(type);
|
||||
list.add(listener);
|
||||
|
||||
if (needOpenListener) {
|
||||
WindowListManager.addOpenListener(this.openListener);
|
||||
}
|
||||
},
|
||||
|
||||
removeListener(type, listener) {
|
||||
if (type == "domwindowopened") {
|
||||
return WindowListManager.removeOpenListener(listener);
|
||||
} else if (type == "domwindowclosed") {
|
||||
return WindowListManager.removeCloseListener(listener);
|
||||
}
|
||||
|
||||
let listeners = this._listeners.get(type);
|
||||
listeners.delete(listener);
|
||||
if (listeners.length == 0) {
|
||||
this._listeners.delete(type);
|
||||
if (this._listeners.size == 0) {
|
||||
WindowListManager.removeOpenListener(this.openListener);
|
||||
}
|
||||
}
|
||||
|
||||
let e = Services.wm.getEnumerator("navigator:browser");
|
||||
while (e.hasMoreElements()) {
|
||||
let window = e.getNext();
|
||||
if (type == "progress") {
|
||||
window.gBrowser.removeTabsProgressListener(listener);
|
||||
} else {
|
||||
window.removeEventListener(type, listener);
|
||||
}
|
||||
}
|
||||
},
|
||||
|
||||
// Runs whenever the "load" event fires for a new window.
|
||||
openListener(window) {
|
||||
for (let [eventType, listeners] of AllWindowEvents._listeners) {
|
||||
for (let listener of listeners) {
|
||||
if (eventType == "progress") {
|
||||
window.gBrowser.addTabsProgressListener(listener);
|
||||
} else {
|
||||
window.addEventListener(eventType, listener);
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
};
|
||||
|
||||
// Subclass of EventManager where we just need to call
|
||||
// add/removeEventListener on each XUL window.
|
||||
global.WindowEventManager = function(context, name, event, listener)
|
||||
{
|
||||
EventManager.call(this, context, name, fire => {
|
||||
let listener2 = (...args) => listener(fire, ...args);
|
||||
AllWindowEvents.addListener(event, listener2);
|
||||
return () => {
|
||||
AllWindowEvents.removeListener(event, listener2);
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
WindowEventManager.prototype = Object.create(EventManager.prototype);
|
|
@ -0,0 +1,151 @@
|
|||
XPCOMUtils.defineLazyModuleGetter(this, "NewTabURL",
|
||||
"resource:///modules/NewTabURL.jsm");
|
||||
|
||||
Cu.import("resource://gre/modules/ExtensionUtils.jsm");
|
||||
let {
|
||||
EventManager,
|
||||
ignoreEvent,
|
||||
runSafe,
|
||||
} = ExtensionUtils;
|
||||
|
||||
extensions.registerAPI((extension, context) => {
|
||||
return {
|
||||
windows: {
|
||||
WINDOW_ID_CURRENT: WindowManager.WINDOW_ID_CURRENT,
|
||||
WINDOW_ID_NONE: WindowManager.WINDOW_ID_NONE,
|
||||
|
||||
onCreated:
|
||||
new WindowEventManager(context, "windows.onCreated", "domwindowopened", (fire, window) => {
|
||||
fire(WindowManager.convert(extension, window));
|
||||
}).api(),
|
||||
|
||||
onRemoved:
|
||||
new WindowEventManager(context, "windows.onRemoved", "domwindowclosed", (fire, window) => {
|
||||
fire(WindowManager.getId(window));
|
||||
}).api(),
|
||||
|
||||
onFocusChanged: new EventManager(context, "windows.onFocusChanged", fire => {
|
||||
// FIXME: This will send multiple messages for a single focus change.
|
||||
let listener = event => {
|
||||
let window = WindowManager.topWindow;
|
||||
let windowId = window ? WindowManager.getId(window) : WindowManager.WINDOW_ID_NONE;
|
||||
fire(windowId);
|
||||
};
|
||||
AllWindowEvents.addListener("focus", listener);
|
||||
AllWindowEvents.addListener("blur", listener);
|
||||
return () => {
|
||||
AllWindowEvents.removeListener("focus", listener);
|
||||
AllWindowEvents.removeListener("blur", listener);
|
||||
};
|
||||
}).api(),
|
||||
|
||||
get: function(windowId, getInfo, callback) {
|
||||
let window = WindowManager.getWindow(windowId);
|
||||
runSafe(context, callback, WindowManager.convert(extension, window, getInfo));
|
||||
},
|
||||
|
||||
getCurrent: function(getInfo, callback) {
|
||||
let window = context.contentWindow;
|
||||
runSafe(context, callback, WindowManager.convert(extension, window, getInfo));
|
||||
},
|
||||
|
||||
getLastFocused: function(...args) {
|
||||
let getInfo, callback;
|
||||
if (args.length == 1) {
|
||||
callback = args[0];
|
||||
} else {
|
||||
[getInfo, callback] = args;
|
||||
}
|
||||
let window = WindowManager.topWindow;
|
||||
runSafe(context, callback, WindowManager.convert(extension, window, getInfo));
|
||||
},
|
||||
|
||||
getAll: function(getAll, callback) {
|
||||
let e = Services.wm.getEnumerator("navigator:browser");
|
||||
let windows = [];
|
||||
while (e.hasMoreElements()) {
|
||||
let window = e.getNext();
|
||||
windows.push(WindowManager.convert(extension, window, getInfo));
|
||||
}
|
||||
runSafe(context, callback, windows);
|
||||
},
|
||||
|
||||
create: function(createData, callback) {
|
||||
function mkstr(s) {
|
||||
let result = Cc["@mozilla.org/supports-string;1"].createInstance(Ci.nsISupportsString);
|
||||
result.data = s;
|
||||
return result;
|
||||
}
|
||||
|
||||
let args = Cc["@mozilla.org/supports-array;1"].createInstance(Ci.nsISupportsArray);
|
||||
if ("url" in createData) {
|
||||
if (Array.isArray(createData.url)) {
|
||||
let array = Cc["@mozilla.org/supports-array;1"].createInstance(Ci.nsISupportsArray);
|
||||
for (let url of createData.url) {
|
||||
array.AppendElement(mkstr(url));
|
||||
}
|
||||
args.AppendElement(array);
|
||||
} else {
|
||||
args.AppendElement(mkstr(createData.url));
|
||||
}
|
||||
} else {
|
||||
args.AppendElement(mkstr(NewTabURL.get()));
|
||||
}
|
||||
|
||||
let extraFeatures = "";
|
||||
if ("incognito" in createData) {
|
||||
if (createData.incognito) {
|
||||
extraFeatures += ",private";
|
||||
} else {
|
||||
extraFeatures += ",non-private";
|
||||
}
|
||||
}
|
||||
|
||||
let window = Services.ww.openWindow(null, "chrome://browser/content/browser.xul", "_blank",
|
||||
"chrome,dialog=no,all" + extraFeatures, args);
|
||||
|
||||
if ("left" in createData || "top" in createData) {
|
||||
let left = "left" in createData ? createData.left : window.screenX;
|
||||
let top = "top" in createData ? createData.top : window.screenY;
|
||||
window.moveTo(left, top);
|
||||
}
|
||||
if ("width" in createData || "height" in createData) {
|
||||
let width = "width" in createData ? createData.width : window.outerWidth;
|
||||
let height = "height" in createData ? createData.height : window.outerHeight;
|
||||
window.resizeTo(width, height);
|
||||
}
|
||||
|
||||
// TODO: focused, type, state
|
||||
|
||||
window.addEventListener("load", function listener() {
|
||||
window.removeEventListener("load", listener);
|
||||
if (callback) {
|
||||
runSafe(context, callback, WindowManager.convert(extension, window));
|
||||
}
|
||||
});
|
||||
},
|
||||
|
||||
update: function(windowId, updateInfo, callback) {
|
||||
let window = WindowManager.getWindow(windowId);
|
||||
if (updateInfo.focused) {
|
||||
Services.focus.activeWindow = window;
|
||||
}
|
||||
// TODO: All the other properties...
|
||||
runSafe(context, callback, WindowManager.convert(extension, window));
|
||||
},
|
||||
|
||||
remove: function(windowId, callback) {
|
||||
let window = WindowManager.getWindow(windowId);
|
||||
window.close();
|
||||
|
||||
let listener = () => {
|
||||
AllWindowEvents.removeListener("domwindowclosed", listener);
|
||||
if (callback) {
|
||||
runSafe(context, callback);
|
||||
}
|
||||
};
|
||||
AllWindowEvents.addListener("domwindowclosed", listener);
|
||||
},
|
||||
},
|
||||
};
|
||||
});
|
|
@ -0,0 +1,19 @@
|
|||
<?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/. -->
|
||||
<svg xmlns="http://www.w3.org/2000/svg" xmlns:xlink="http://www.w3.org/1999/xlink"
|
||||
width="64" height="64" viewBox="0 0 64 64">
|
||||
<defs>
|
||||
<style>
|
||||
.style-puzzle-piece {
|
||||
fill: url('#gradient-linear-puzzle-piece');
|
||||
}
|
||||
</style>
|
||||
<linearGradient id="gradient-linear-puzzle-piece" x1="0%" y1="0%" x2="0%" y2="100%">
|
||||
<stop offset="0%" stop-color="#66cc52" stop-opacity="1"/>
|
||||
<stop offset="100%" stop-color="#60bf4c" stop-opacity="1"/>
|
||||
</linearGradient>
|
||||
</defs>
|
||||
<path class="style-puzzle-piece" d="M42,62c2.2,0,4-1.8,4-4l0-14.2c0,0,0.4-3.7,2.8-3.7c2.4,0,2.2,3.9,6.7,3.9c2.3,0,6.2-1.2,6.2-8.2 c0-7-3.9-7.9-6.2-7.9c-4.5,0-4.3,3.7-6.7,3.7c-2.4,0-2.8-3.8-2.8-3.8V22c0-2.2-1.8-4-4-4H31.5c0,0-3.4-0.6-3.4-3 c0-2.4,3.8-2.6,3.8-7.1c0-2.3-1.3-5.9-8.3-5.9s-8,3.6-8,5.9c0,4.5,3.4,4.7,3.4,7.1c0,2.4-3.4,3-3.4,3H6c-2.2,0-4,1.8-4,4l0,7.8 c0,0-0.4,6,4.4,6c3.1,0,3.2-4.1,7.3-4.1c2,0,4,1.9,4,6c0,4.2-2,6.3-4,6.3c-4,0-4.2-4.1-7.3-4.1c-4.8,0-4.4,5.8-4.4,5.8L2,58 c0,2.2,1.8,4,4,4H19c0,0,6.3,0.4,6.3-4.4c0-3.1-4-3.6-4-7.7c0-2,2.2-4.5,6.4-4.5c4.2,0,6.6,2.5,6.6,4.5c0,4-3.9,4.6-3.9,7.7 c0,4.9,6.3,4.4,6.3,4.4H42z"/>
|
||||
</svg>
|
После Ширина: | Высота: | Размер: 1.4 KiB |
|
@ -0,0 +1,11 @@
|
|||
# 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/extension.svg (extension.svg)
|
||||
content/browser/ext-utils.js (ext-utils.js)
|
||||
content/browser/ext-contextMenus.js (ext-contextMenus.js)
|
||||
content/browser/ext-browserAction.js (ext-browserAction.js)
|
||||
content/browser/ext-tabs.js (ext-tabs.js)
|
||||
content/browser/ext-windows.js (ext-windows.js)
|
|
@ -1,7 +1,7 @@
|
|||
# -*- 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/.
|
||||
|
||||
browser.jar:
|
||||
content/browser/readinglist/sidebar.xhtml
|
||||
content/browser/readinglist/sidebar.js
|
||||
JAR_MANIFESTS += ['jar.mn']
|
|
@ -0,0 +1,83 @@
|
|||
#!/usr/bin/env 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/.
|
||||
|
||||
import argparse
|
||||
import json
|
||||
import uuid
|
||||
import sys
|
||||
import os.path
|
||||
|
||||
parser = argparse.ArgumentParser(description='Create install.rdf from manifest.json')
|
||||
parser.add_argument('--locale')
|
||||
parser.add_argument('--profile')
|
||||
parser.add_argument('--uuid')
|
||||
parser.add_argument('dir')
|
||||
args = parser.parse_args()
|
||||
|
||||
manifestFile = os.path.join(args.dir, 'manifest.json')
|
||||
manifest = json.load(open(manifestFile))
|
||||
|
||||
locale = args.locale
|
||||
if not locale:
|
||||
locale = manifest.get('default_locale', 'en-US')
|
||||
|
||||
def process_locale(s):
|
||||
if s.startswith('__MSG_') and s.endswith('__'):
|
||||
tag = s[6:-2]
|
||||
path = os.path.join(args.dir, '_locales', locale, 'messages.json')
|
||||
data = json.load(open(path))
|
||||
return data[tag]['message']
|
||||
else:
|
||||
return s
|
||||
|
||||
id = args.uuid
|
||||
if not id:
|
||||
id = '{' + str(uuid.uuid4()) + '}'
|
||||
|
||||
name = process_locale(manifest['name'])
|
||||
desc = process_locale(manifest['description'])
|
||||
version = manifest['version']
|
||||
|
||||
installFile = open(os.path.join(args.dir, 'install.rdf'), 'w')
|
||||
print >>installFile, '<?xml version="1.0"?>'
|
||||
print >>installFile, '<RDF xmlns="http://www.w3.org/1999/02/22-rdf-syntax-ns#"'
|
||||
print >>installFile, ' xmlns:em="http://www.mozilla.org/2004/em-rdf#">'
|
||||
print >>installFile
|
||||
print >>installFile, ' <Description about="urn:mozilla:install-manifest">'
|
||||
print >>installFile, ' <em:id>{}</em:id>'.format(id)
|
||||
print >>installFile, ' <em:type>2</em:type>'
|
||||
print >>installFile, ' <em:name>{}</em:name>'.format(name)
|
||||
print >>installFile, ' <em:description>{}</em:description>'.format(desc)
|
||||
print >>installFile, ' <em:version>{}</em:version>'.format(version)
|
||||
print >>installFile, ' <em:bootstrap>true</em:bootstrap>'
|
||||
|
||||
print >>installFile, ' <em:targetApplication>'
|
||||
print >>installFile, ' <Description>'
|
||||
print >>installFile, ' <em:id>{ec8030f7-c20a-464f-9b0e-13a3a9e97384}</em:id>'
|
||||
print >>installFile, ' <em:minVersion>4.0</em:minVersion>'
|
||||
print >>installFile, ' <em:maxVersion>50.0</em:maxVersion>'
|
||||
print >>installFile, ' </Description>'
|
||||
print >>installFile, ' </em:targetApplication>'
|
||||
|
||||
print >>installFile, ' </Description>'
|
||||
print >>installFile, '</RDF>'
|
||||
installFile.close()
|
||||
|
||||
bootstrapPath = os.path.join(os.path.dirname(sys.argv[0]), 'bootstrap.js')
|
||||
data = open(bootstrapPath).read()
|
||||
boot = open(os.path.join(args.dir, 'bootstrap.js'), 'w')
|
||||
boot.write(data)
|
||||
boot.close()
|
||||
|
||||
if args.profile:
|
||||
os.system('mkdir -p {}/extensions'.format(args.profile))
|
||||
output = open(args.profile + '/extensions/' + id, 'w')
|
||||
print >>output, os.path.realpath(args.dir)
|
||||
output.close()
|
||||
else:
|
||||
dir = os.path.realpath(args.dir)
|
||||
if dir[-1] == os.sep:
|
||||
dir = dir[:-1]
|
||||
os.system('cd "{}"; zip ../"{}".xpi -r *'.format(args.dir, os.path.basename(dir)))
|
|
@ -22,6 +22,7 @@ content/js/panel.js
|
|||
content/js/roomViews.js
|
||||
content/js/feedbackViews.js
|
||||
content/shared/js/textChatView.js
|
||||
content/shared/js/linkifiedTextView.js
|
||||
content/shared/js/views.js
|
||||
standalone/content/js/fxOSMarketplace.js
|
||||
standalone/content/js/standaloneRoomViews.js
|
||||
|
|
|
@ -39,6 +39,8 @@
|
|||
<script type="text/javascript" src="loop/js/feedbackViews.js"></script>
|
||||
<script type="text/javascript" src="loop/shared/js/textChatStore.js"></script>
|
||||
<script type="text/javascript" src="loop/shared/js/textChatView.js"></script>
|
||||
<script type="text/javascript" src="loop/shared/js/linkifiedTextView.js"></script>
|
||||
<script type="text/javascript" src="loop/shared/js/urlRegExps.js"></script>
|
||||
<script type="text/javascript" src="loop/js/conversationViews.js"></script>
|
||||
<script type="text/javascript" src="loop/shared/js/websocket.js"></script>
|
||||
<script type="text/javascript" src="loop/js/conversationAppStore.js"></script>
|
||||
|
|
|
@ -0,0 +1,114 @@
|
|||
/* 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/. */
|
||||
|
||||
var loop = loop || {};
|
||||
loop.shared = loop.shared || {};
|
||||
loop.shared.views = loop.shared.views || {};
|
||||
loop.shared.views.LinkifiedTextView = (function(mozL10n) {
|
||||
"use strict";
|
||||
|
||||
/**
|
||||
* Given a rawText property, renderer a version of that text with any
|
||||
* links starting with http://, https://, or ftp:// as actual clickable
|
||||
* links inside a <p> container.
|
||||
*/
|
||||
var LinkifiedTextView = React.createClass({displayName: "LinkifiedTextView",
|
||||
propTypes: {
|
||||
// Call this instead of allowing the default <a> click semantics, if
|
||||
// given. Also causes sendReferrer and suppressTarget attributes to be
|
||||
// ignored.
|
||||
linkClickHandler: React.PropTypes.func,
|
||||
// The text to be linkified.
|
||||
rawText: React.PropTypes.string.isRequired,
|
||||
// Should the links send a referrer? Defaults to false.
|
||||
sendReferrer: React.PropTypes.bool,
|
||||
// Should we suppress target="_blank" on the link? Defaults to false.
|
||||
// Mostly for testing use.
|
||||
suppressTarget: React.PropTypes.bool
|
||||
},
|
||||
|
||||
mixins: [
|
||||
React.addons.PureRenderMixin
|
||||
],
|
||||
|
||||
_handleClickEvent: function(e) {
|
||||
e.preventDefault();
|
||||
e.stopPropagation();
|
||||
|
||||
this.props.linkClickHandler(e.currentTarget.href);
|
||||
},
|
||||
|
||||
_generateLinkAttributes: function(href) {
|
||||
var linkAttributes = {
|
||||
href: href
|
||||
};
|
||||
|
||||
if (this.props.linkClickHandler) {
|
||||
linkAttributes.onClick = this._handleClickEvent;
|
||||
|
||||
// if this is specified, we short-circuit return to avoid unnecessarily
|
||||
// creating target and rel attributes.
|
||||
return linkAttributes;
|
||||
}
|
||||
|
||||
if (!this.props.suppressTarget) {
|
||||
linkAttributes.target = "_blank";
|
||||
}
|
||||
|
||||
if (!this.props.sendReferrer) {
|
||||
linkAttributes.rel = "noreferrer";
|
||||
}
|
||||
|
||||
return linkAttributes;
|
||||
},
|
||||
|
||||
/** a
|
||||
* Parse the given string into an array of strings and React <a> elements
|
||||
* in the order in which they should be rendered (i.e. FIFO).
|
||||
*
|
||||
* @param {String} s the raw string to be parsed
|
||||
*
|
||||
* @returns {Array} of strings and React <a> elements in order.
|
||||
*/
|
||||
parseStringToElements: function(s) {
|
||||
var elements = [];
|
||||
var result = loop.shared.urlRegExps.fullUrlMatch.exec(s);
|
||||
var reactElementsCounter = 0; // For giving keys to each ReactElement.
|
||||
|
||||
while (result) {
|
||||
// If there's text preceding the first link, push it onto the array
|
||||
// and update the string pointer.
|
||||
if (result.index) {
|
||||
elements.push(s.substr(0, result.index));
|
||||
s = s.substr(result.index);
|
||||
}
|
||||
|
||||
// Push the first link itself, and advance the string pointer again.
|
||||
elements.push(
|
||||
React.createElement("a", React.__spread({}, this._generateLinkAttributes(result[0]) ,
|
||||
{key: reactElementsCounter++}),
|
||||
result[0]
|
||||
)
|
||||
);
|
||||
s = s.substr(result[0].length);
|
||||
|
||||
// Check for another link, and perhaps continue...
|
||||
result = loop.shared.urlRegExps.fullUrlMatch.exec(s);
|
||||
}
|
||||
|
||||
if (s) {
|
||||
elements.push(s);
|
||||
}
|
||||
|
||||
return elements;
|
||||
},
|
||||
|
||||
render: function () {
|
||||
return ( React.createElement("p", null, this.parseStringToElements(this.props.rawText) ) );
|
||||
}
|
||||
});
|
||||
|
||||
return LinkifiedTextView;
|
||||
|
||||
})(navigator.mozL10n || document.mozL10n);
|
|
@ -0,0 +1,114 @@
|
|||
/* 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/. */
|
||||
|
||||
var loop = loop || {};
|
||||
loop.shared = loop.shared || {};
|
||||
loop.shared.views = loop.shared.views || {};
|
||||
loop.shared.views.LinkifiedTextView = (function(mozL10n) {
|
||||
"use strict";
|
||||
|
||||
/**
|
||||
* Given a rawText property, renderer a version of that text with any
|
||||
* links starting with http://, https://, or ftp:// as actual clickable
|
||||
* links inside a <p> container.
|
||||
*/
|
||||
var LinkifiedTextView = React.createClass({
|
||||
propTypes: {
|
||||
// Call this instead of allowing the default <a> click semantics, if
|
||||
// given. Also causes sendReferrer and suppressTarget attributes to be
|
||||
// ignored.
|
||||
linkClickHandler: React.PropTypes.func,
|
||||
// The text to be linkified.
|
||||
rawText: React.PropTypes.string.isRequired,
|
||||
// Should the links send a referrer? Defaults to false.
|
||||
sendReferrer: React.PropTypes.bool,
|
||||
// Should we suppress target="_blank" on the link? Defaults to false.
|
||||
// Mostly for testing use.
|
||||
suppressTarget: React.PropTypes.bool
|
||||
},
|
||||
|
||||
mixins: [
|
||||
React.addons.PureRenderMixin
|
||||
],
|
||||
|
||||
_handleClickEvent: function(e) {
|
||||
e.preventDefault();
|
||||
e.stopPropagation();
|
||||
|
||||
this.props.linkClickHandler(e.currentTarget.href);
|
||||
},
|
||||
|
||||
_generateLinkAttributes: function(href) {
|
||||
var linkAttributes = {
|
||||
href: href
|
||||
};
|
||||
|
||||
if (this.props.linkClickHandler) {
|
||||
linkAttributes.onClick = this._handleClickEvent;
|
||||
|
||||
// if this is specified, we short-circuit return to avoid unnecessarily
|
||||
// creating target and rel attributes.
|
||||
return linkAttributes;
|
||||
}
|
||||
|
||||
if (!this.props.suppressTarget) {
|
||||
linkAttributes.target = "_blank";
|
||||
}
|
||||
|
||||
if (!this.props.sendReferrer) {
|
||||
linkAttributes.rel = "noreferrer";
|
||||
}
|
||||
|
||||
return linkAttributes;
|
||||
},
|
||||
|
||||
/** a
|
||||
* Parse the given string into an array of strings and React <a> elements
|
||||
* in the order in which they should be rendered (i.e. FIFO).
|
||||
*
|
||||
* @param {String} s the raw string to be parsed
|
||||
*
|
||||
* @returns {Array} of strings and React <a> elements in order.
|
||||
*/
|
||||
parseStringToElements: function(s) {
|
||||
var elements = [];
|
||||
var result = loop.shared.urlRegExps.fullUrlMatch.exec(s);
|
||||
var reactElementsCounter = 0; // For giving keys to each ReactElement.
|
||||
|
||||
while (result) {
|
||||
// If there's text preceding the first link, push it onto the array
|
||||
// and update the string pointer.
|
||||
if (result.index) {
|
||||
elements.push(s.substr(0, result.index));
|
||||
s = s.substr(result.index);
|
||||
}
|
||||
|
||||
// Push the first link itself, and advance the string pointer again.
|
||||
elements.push(
|
||||
<a { ...this._generateLinkAttributes(result[0]) }
|
||||
key={reactElementsCounter++}>
|
||||
{result[0]}
|
||||
</a>
|
||||
);
|
||||
s = s.substr(result[0].length);
|
||||
|
||||
// Check for another link, and perhaps continue...
|
||||
result = loop.shared.urlRegExps.fullUrlMatch.exec(s);
|
||||
}
|
||||
|
||||
if (s) {
|
||||
elements.push(s);
|
||||
}
|
||||
|
||||
return elements;
|
||||
},
|
||||
|
||||
render: function () {
|
||||
return ( <p>{ this.parseStringToElements(this.props.rawText) }</p> );
|
||||
}
|
||||
});
|
||||
|
||||
return LinkifiedTextView;
|
||||
|
||||
})(navigator.mozL10n || document.mozL10n);
|
|
@ -56,9 +56,15 @@ loop.shared.views.chat = (function(mozL10n) {
|
|||
"room-name": this.props.contentType === CHAT_CONTENT_TYPES.ROOM_NAME
|
||||
});
|
||||
|
||||
var optionalProps = {};
|
||||
if (navigator.mozLoop) {
|
||||
optionalProps.linkClickHandler = navigator.mozLoop.openURL;
|
||||
}
|
||||
|
||||
return (
|
||||
React.createElement("div", {className: classes},
|
||||
React.createElement("p", null, this.props.message),
|
||||
React.createElement(sharedViews.LinkifiedTextView, React.__spread({}, optionalProps,
|
||||
{rawText: this.props.message})),
|
||||
React.createElement("span", {className: "text-chat-arrow"}),
|
||||
this.props.showTimestamp ? this._renderTimestamp() : null
|
||||
)
|
||||
|
|
|
@ -56,9 +56,15 @@ loop.shared.views.chat = (function(mozL10n) {
|
|||
"room-name": this.props.contentType === CHAT_CONTENT_TYPES.ROOM_NAME
|
||||
});
|
||||
|
||||
var optionalProps = {};
|
||||
if (navigator.mozLoop) {
|
||||
optionalProps.linkClickHandler = navigator.mozLoop.openURL;
|
||||
}
|
||||
|
||||
return (
|
||||
<div className={classes}>
|
||||
<p>{this.props.message}</p>
|
||||
<sharedViews.LinkifiedTextView {...optionalProps}
|
||||
rawText={this.props.message} />
|
||||
<span className="text-chat-arrow" />
|
||||
{this.props.showTimestamp ? this._renderTimestamp() : null}
|
||||
</div>
|
||||
|
|
|
@ -0,0 +1,72 @@
|
|||
// This is derived from Diego Perini's code,
|
||||
// currently available at https://gist.github.com/dperini/729294
|
||||
|
||||
// Regular Expression for URL validation
|
||||
//
|
||||
// Original Author: Diego Perini
|
||||
// License: MIT
|
||||
//
|
||||
// Copyright (c) 2010-2013 Diego Perini (http://www.iport.it)
|
||||
//
|
||||
// Permission is hereby granted, free of charge, to any person
|
||||
// obtaining a copy of this software and associated documentation
|
||||
// files (the "Software"), to deal in the Software without
|
||||
// restriction, including without limitation the rights to use,
|
||||
// copy, modify, merge, publish, distribute, sublicense, and/or sell
|
||||
// copies of the Software, and to permit persons to whom the
|
||||
// Software is furnished to do so, subject to the following
|
||||
// conditions:
|
||||
//
|
||||
// The above copyright notice and this permission notice shall be
|
||||
// included in all copies or substantial portions of the Software.
|
||||
//
|
||||
// THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND,
|
||||
// EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES
|
||||
// OF MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND
|
||||
// NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT
|
||||
// HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY,
|
||||
// WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING
|
||||
// FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR
|
||||
// OTHER DEALINGS IN THE SOFTWARE.
|
||||
//
|
||||
|
||||
var loop = loop || {};
|
||||
loop.shared = loop.shared || {};
|
||||
loop.shared.urlRegExps = (function() {
|
||||
|
||||
"use strict";
|
||||
|
||||
// Some https://wiki.mozilla.org/Loop/Development/RegExpDebugging for tools
|
||||
// if you need to debug changes to this:
|
||||
|
||||
var fullUrlMatch = new RegExp(
|
||||
// Protocol identifier.
|
||||
"(?:(?:https?|ftp)://)" +
|
||||
// User:pass authentication.
|
||||
"((?:\\S+(?::\\S*)?@)?" +
|
||||
"(?:" +
|
||||
// IP address dotted notation octets:
|
||||
// excludes loopback network 0.0.0.0,
|
||||
// excludes reserved space >= 224.0.0.0,
|
||||
// excludes network & broadcast addresses,
|
||||
// (first & last IP address of each class).
|
||||
"(?:[1-9]\\d?|1\\d\\d|2[01]\\d|22[0-3])" +
|
||||
"(?:\\.(?:1?\\d{1,2}|2[0-4]\\d|25[0-5])){2}" +
|
||||
"(?:\\.(?:[1-9]\\d?|1\\d\\d|2[0-4]\\d|25[0-4]))" +
|
||||
"|" +
|
||||
// Host name.
|
||||
"(?:(?:[a-z\\u00a1-\\uffff0-9]-*)*[a-z\\u00a1-\\uffff0-9]+)" +
|
||||
// Domain name.
|
||||
"(?:\\.(?:[a-z\\u00a1-\\uffff0-9]-*)*[a-z\\u00a1-\\uffff0-9]+)*" +
|
||||
// TLD identifier.
|
||||
"(?:\\.(?:[a-z\\u00a1-\\uffff]{2,})))" +
|
||||
// Port number.
|
||||
"(?::\\d{2,5})?" +
|
||||
// Resource path.
|
||||
"(?:[/?#]\\S*)?)", "i");
|
||||
|
||||
return {
|
||||
fullUrlMatch: fullUrlMatch
|
||||
};
|
||||
|
||||
})();
|
|
@ -86,8 +86,10 @@ browser.jar:
|
|||
content/browser/loop/shared/js/mixins.js (content/shared/js/mixins.js)
|
||||
content/browser/loop/shared/js/otSdkDriver.js (content/shared/js/otSdkDriver.js)
|
||||
content/browser/loop/shared/js/views.js (content/shared/js/views.js)
|
||||
content/browser/loop/shared/js/linkifiedTextView.js (content/shared/js/linkifiedTextView.js)
|
||||
content/browser/loop/shared/js/textChatStore.js (content/shared/js/textChatStore.js)
|
||||
content/browser/loop/shared/js/textChatView.js (content/shared/js/textChatView.js)
|
||||
content/browser/loop/shared/js/urlRegExps.js (content/shared/js/urlRegExps.js)
|
||||
content/browser/loop/shared/js/utils.js (content/shared/js/utils.js)
|
||||
content/browser/loop/shared/js/validate.js (content/shared/js/validate.js)
|
||||
content/browser/loop/shared/js/websocket.js (content/shared/js/websocket.js)
|
||||
|
|
|
@ -144,9 +144,10 @@
|
|||
<script type="text/javascript" src="shared/js/fxOSActiveRoomStore.js"></script>
|
||||
<script type="text/javascript" src="shared/js/activeRoomStore.js"></script>
|
||||
<script type="text/javascript" src="shared/js/views.js"></script>
|
||||
<script type="text/javascript" src="shared/js/feedbackViews.js"></script>
|
||||
<script type="text/javascript" src="shared/js/textChatStore.js"></script>
|
||||
<script type="text/javascript" src="shared/js/textChatView.js"></script>
|
||||
<script type="text/javascript" src="shared/js/urlRegExps.js"></script>
|
||||
<script type="text/javascript" src="shared/js/linkifiedTextView.js"></script>
|
||||
<script type="text/javascript" src="js/standaloneAppStore.js"></script>
|
||||
<script type="text/javascript" src="js/standaloneClient.js"></script>
|
||||
<script type="text/javascript" src="js/standaloneMozLoop.js"></script>
|
||||
|
|
|
@ -34,6 +34,8 @@ module.exports = function(config) {
|
|||
"content/shared/js/views.js",
|
||||
"content/shared/js/textChatStore.js",
|
||||
"content/shared/js/textChatView.js",
|
||||
"content/shared/js/urlRegExps.js",
|
||||
"content/shared/js/linkifiedTextView.js",
|
||||
"standalone/content/js/multiplexGum.js",
|
||||
"standalone/content/js/standaloneAppStore.js",
|
||||
"standalone/content/js/standaloneClient.js",
|
||||
|
|
|
@ -63,6 +63,8 @@
|
|||
<script src="../../content/shared/js/views.js"></script>
|
||||
<script src="../../content/shared/js/textChatStore.js"></script>
|
||||
<script src="../../content/shared/js/textChatView.js"></script>
|
||||
<script src="../../content/shared/js/urlRegExps.js"></script>
|
||||
<script src="../../content/shared/js/linkifiedTextView.js"></script>
|
||||
|
||||
<!-- Test scripts -->
|
||||
<script src="models_test.js"></script>
|
||||
|
@ -80,6 +82,8 @@
|
|||
<script src="store_test.js"></script>
|
||||
<script src="textChatStore_test.js"></script>
|
||||
<script src="textChatView_test.js"></script>
|
||||
<script src="linkifiedTextView_test.js"></script>
|
||||
|
||||
<script>
|
||||
describe("Uncaught Error Check", function() {
|
||||
it("should load the tests without errors", function() {
|
||||
|
|
|
@ -0,0 +1,378 @@
|
|||
/*
|
||||
* Many of these tests are ported from Autolinker.js:
|
||||
*
|
||||
* https://github.com/gregjacobs/Autolinker.js/blob/master/tests/AutolinkerSpec.js
|
||||
*
|
||||
* which is released under the MIT license. Thanks to Greg Jacobs for his hard
|
||||
* work there.
|
||||
*
|
||||
* The MIT License (MIT)
|
||||
* Original Work Copyright (c) 2014 Gregory Jacobs (http://greg-jacobs.com)
|
||||
*
|
||||
* Permission is hereby granted, free of charge, to any person obtaining
|
||||
* a copy of this software and associated documentation files (the
|
||||
* "Software"), to deal in the Software without restriction, including
|
||||
* without limitation the rights to use, copy, modify, merge, publish,
|
||||
* distribute, sublicense, and/or sell copies of the Software, and to
|
||||
* permit persons to whom the Software is furnished to do so, subject to the
|
||||
* following conditions:
|
||||
*
|
||||
* The above copyright notice and this permission notice shall be included
|
||||
* in all copies or substantial portions of the Software.
|
||||
*
|
||||
* THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
|
||||
* IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
|
||||
* FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
|
||||
* AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
|
||||
* LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
|
||||
* OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
|
||||
* SOFTWARE.
|
||||
*/
|
||||
describe("loop.shared.views.LinkifiedTextView", function () {
|
||||
"use strict";
|
||||
|
||||
var expect = chai.expect;
|
||||
var LinkifiedTextView = loop.shared.views.LinkifiedTextView;
|
||||
var TestUtils = React.addons.TestUtils;
|
||||
|
||||
describe("LinkifiedTextView", function () {
|
||||
function renderToMarkup(string, extraProps) {
|
||||
return React.renderToStaticMarkup(
|
||||
React.createElement(
|
||||
LinkifiedTextView,
|
||||
_.extend({rawText: string}, extraProps)));
|
||||
}
|
||||
|
||||
describe("#render", function() {
|
||||
function testRender(testData) {
|
||||
it(testData.desc, function() {
|
||||
var markup = renderToMarkup(testData.rawText,
|
||||
{suppressTarget: true, sendReferrer: true});
|
||||
|
||||
expect(markup).to.equal(testData.markup);
|
||||
});
|
||||
}
|
||||
|
||||
function testSkip(testData) {
|
||||
it.skip(testData.desc, function() {
|
||||
var markup = renderToMarkup(testData.rawText,
|
||||
{suppressTarget: true, sendReferrer: true});
|
||||
|
||||
expect(markup).to.equal(testData.markup);
|
||||
});
|
||||
}
|
||||
|
||||
describe("this.props.suppressTarget", function() {
|
||||
it("should make links w/o a target attr if suppressTarget is true",
|
||||
function() {
|
||||
var markup = renderToMarkup("http://example.com", {suppressTarget: true});
|
||||
|
||||
expect(markup).to.equal(
|
||||
'<p><a href="http://example.com" rel="noreferrer">http://example.com</a></p>');
|
||||
});
|
||||
|
||||
it("should make links with target=_blank if suppressTarget is not given",
|
||||
function() {
|
||||
var markup = renderToMarkup("http://example.com", {});
|
||||
|
||||
expect(markup).to.equal(
|
||||
'<p><a href="http://example.com" target="_blank" rel="noreferrer">http://example.com</a></p>');
|
||||
});
|
||||
});
|
||||
|
||||
describe("this.props.sendReferrer", function() {
|
||||
it("should make links w/o rel=noreferrer if sendReferrer is true",
|
||||
function() {
|
||||
var markup = renderToMarkup("http://example.com", {sendReferrer: true});
|
||||
|
||||
expect(markup).to.equal(
|
||||
'<p><a href="http://example.com" target="_blank">http://example.com</a></p>');
|
||||
});
|
||||
|
||||
it("should make links with rel=noreferrer if sendReferrer is not given",
|
||||
function() {
|
||||
var markup = renderToMarkup("http://example.com", {});
|
||||
|
||||
expect(markup).to.equal(
|
||||
'<p><a href="http://example.com" target="_blank" rel="noreferrer">http://example.com</a></p>');
|
||||
});
|
||||
});
|
||||
|
||||
describe("this.props.linkClickHandler", function () {
|
||||
function mountTestComponent(string, extraProps) {
|
||||
return TestUtils.renderIntoDocument(
|
||||
React.createElement(
|
||||
LinkifiedTextView,
|
||||
_.extend({rawText: string}, extraProps)));
|
||||
}
|
||||
|
||||
it("should be called when a generated link is clicked", function () {
|
||||
var fakeUrl = "http://example.com";
|
||||
var linkClickHandler = sinon.stub();
|
||||
var comp = mountTestComponent(fakeUrl, {linkClickHandler: linkClickHandler});
|
||||
|
||||
TestUtils.Simulate.click(comp.getDOMNode().querySelector("a"));
|
||||
|
||||
sinon.assert.calledOnce(linkClickHandler);
|
||||
});
|
||||
|
||||
it("should cause sendReferrer and suppressTarget props to be ignored",
|
||||
function() {
|
||||
var fakeUrl = "http://example.com";
|
||||
var linkClickHandler = function() {};
|
||||
|
||||
var markup = renderToMarkup("http://example.com", {
|
||||
linkClickHandler: linkClickHandler,
|
||||
sendReferrer: false,
|
||||
suppressTarget: false
|
||||
});
|
||||
|
||||
expect(markup).to.equal(
|
||||
'<p><a href="http://example.com">http://example.com</a></p>');
|
||||
});
|
||||
|
||||
describe("#_handleClickEvent", function () {
|
||||
var fakeEvent;
|
||||
var fakeUrl = "http://example.com";
|
||||
|
||||
beforeEach(function() {
|
||||
fakeEvent = {
|
||||
currentTarget: { href: fakeUrl },
|
||||
preventDefault: sinon.stub(),
|
||||
stopPropagation: sinon.stub()
|
||||
};
|
||||
});
|
||||
|
||||
it("should call preventDefault on the given event", function () {
|
||||
function linkClickHandler() {}
|
||||
var comp = mountTestComponent(
|
||||
fakeUrl, {linkClickHandler: linkClickHandler});
|
||||
|
||||
comp._handleClickEvent(fakeEvent);
|
||||
|
||||
sinon.assert.calledOnce(fakeEvent.preventDefault);
|
||||
sinon.assert.calledWithExactly(fakeEvent.stopPropagation);
|
||||
});
|
||||
|
||||
it("should call stopPropagation on the given event", function () {
|
||||
function linkClickHandler() {}
|
||||
var comp = mountTestComponent(
|
||||
fakeUrl, {linkClickHandler: linkClickHandler});
|
||||
|
||||
comp._handleClickEvent(fakeEvent);
|
||||
|
||||
sinon.assert.calledOnce(fakeEvent.stopPropagation);
|
||||
sinon.assert.calledWithExactly(fakeEvent.stopPropagation);
|
||||
});
|
||||
|
||||
it("should call this.props.linkClickHandler with event.currentTarget.href", function () {
|
||||
var linkClickHandler = sinon.stub();
|
||||
var comp = mountTestComponent(
|
||||
fakeUrl, {linkClickHandler: linkClickHandler});
|
||||
|
||||
comp._handleClickEvent(fakeEvent);
|
||||
|
||||
sinon.assert.calledOnce(linkClickHandler);
|
||||
sinon.assert.calledWithExactly(linkClickHandler, fakeUrl);
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
// Note that these are really integration tests with the parser and React.
|
||||
// Since we're depending on that integration to provide us with security
|
||||
// against various injection problems, it feels fairly important. That
|
||||
// said, these tests are not terribly robust in the face of markup changes
|
||||
// in the code, and over time, some of them may want to be pushed down
|
||||
// to only be unit tests against the parser or against
|
||||
// parseStringToElements. We may also want both unit and integration
|
||||
// testing for some subset of these.
|
||||
var tests = [
|
||||
{
|
||||
desc: "should only add a container to a string with no URLs",
|
||||
rawText: "This is a test.",
|
||||
markup: "<p>This is a test.</p>"
|
||||
},
|
||||
{
|
||||
desc: "should linkify a string containing only a URL",
|
||||
rawText: "http://example.com/",
|
||||
markup: '<p><a href="http://example.com/">http://example.com/</a></p>'
|
||||
},
|
||||
{
|
||||
desc: "should linkify a URL with text preceding it",
|
||||
rawText: "This is a link to http://example.com",
|
||||
markup: '<p>This is a link to <a href="http://example.com">http://example.com</a></p>'
|
||||
},
|
||||
{
|
||||
desc: "should linkify a URL with text before and after",
|
||||
rawText: "Look at http://example.com near the bottom",
|
||||
markup: '<p>Look at <a href="http://example.com">http://example.com</a> near the bottom</p>'
|
||||
},
|
||||
{
|
||||
desc: "should linkify an http URL",
|
||||
rawText: "This is an http://example.com test",
|
||||
markup: '<p>This is an <a href="http://example.com">http://example.com</a> test</p>'
|
||||
},
|
||||
{
|
||||
desc: "should linkify an https URL",
|
||||
rawText: "This is an https://example.com test",
|
||||
markup: '<p>This is an <a href="https://example.com">https://example.com</a> test</p>'
|
||||
},
|
||||
{
|
||||
desc: "should not linkify a data URL",
|
||||
rawText: "This is an data:image/png;base64,iVBORw0KGgoAAA test",
|
||||
markup: "<p>This is an data:image/png;base64,iVBORw0KGgoAAA test</p>"
|
||||
},
|
||||
{
|
||||
desc: "should linkify URLs with a port number",
|
||||
rawText: "Joe went to http://example.com:8000 today.",
|
||||
markup: '<p>Joe went to <a href="http://example.com:8000">http://example.com:8000</a> today.</p>'
|
||||
},
|
||||
{
|
||||
desc: "should linkify URLs with a port number and a trailing slash",
|
||||
rawText: "Joe went to http://example.com:8000/ today.",
|
||||
markup: '<p>Joe went to <a href="http://example.com:8000/">http://example.com:8000/</a> today.</p>'
|
||||
},
|
||||
{
|
||||
desc: "should linkify URLs with a port number and a path",
|
||||
rawText: "Joe went to http://example.com:8000/mysite/page today.",
|
||||
markup: '<p>Joe went to <a href="http://example.com:8000/mysite/page">http://example.com:8000/mysite/page</a> today.</p>'
|
||||
},
|
||||
{
|
||||
desc: "should linkify URLs with a port number and a query string",
|
||||
rawText: "Joe went to http://example.com:8000?page=index today.",
|
||||
markup: '<p>Joe went to <a href="http://example.com:8000?page=index">http://example.com:8000?page=index</a> today.</p>'
|
||||
},
|
||||
{
|
||||
desc: "should linkify a URL with a port number and a hash string",
|
||||
rawText: "Joe went to http://example.com:8000#page=index today.",
|
||||
markup: '<p>Joe went to <a href="http://example.com:8000#page=index">http://example.com:8000#page=index</a> today.</p>'
|
||||
},
|
||||
{
|
||||
desc: "should NOT include preceding ':' intros without a space",
|
||||
rawText: "the link:http://example.com/",
|
||||
markup: '<p>the link:<a href="http://example.com/">http://example.com/</a></p>'
|
||||
},
|
||||
{
|
||||
desc: "should NOT autolink URLs with 'javascript:' URI scheme",
|
||||
rawText: "do not link javascript:window.alert('hi') please",
|
||||
markup: "<p>do not link javascript:window.alert('hi') please</p>"
|
||||
},
|
||||
{
|
||||
desc: "should NOT autolink URLs with the 'JavAscriPt:' scheme",
|
||||
rawText: "do not link JavAscriPt:window.alert('hi') please",
|
||||
markup: "<p>do not link JavAscriPt:window.alert('hi') please</p>"
|
||||
},
|
||||
{
|
||||
desc: "should NOT autolink possible URLs with the 'vbscript:' URI scheme",
|
||||
rawText: "do not link vbscript:window.alert('hi') please",
|
||||
markup: "<p>do not link vbscript:window.alert('hi') please</p>"
|
||||
},
|
||||
{
|
||||
desc: "should NOT autolink URLs with the 'vBsCriPt:' URI scheme",
|
||||
rawText: "do not link vBsCriPt:window.alert('hi') please",
|
||||
markup: "<p>do not link vBsCriPt:window.alert('hi') please</p>"
|
||||
},
|
||||
{
|
||||
desc: "should NOT autolink a string in the form of 'version:1.0'",
|
||||
rawText: "version:1.0",
|
||||
markup: "<p>version:1.0</p>"
|
||||
},
|
||||
{
|
||||
desc: "should linkify an ftp URL",
|
||||
rawText: "This is an ftp://example.com test",
|
||||
markup: '<p>This is an <a href="ftp://example.com">ftp://example.com</a> test</p>'
|
||||
},
|
||||
|
||||
// We don't want to include trailing dots in URLs, even though those
|
||||
// are valid DNS names, as that should match user intent more of the
|
||||
// time, as well as avoid this stuff:
|
||||
//
|
||||
// http://saynt2day.blogspot.it/2013/03/danger-of-trailing-dot-in-domain-name.html
|
||||
//
|
||||
{
|
||||
desc: "should linkify 'http://example.com.', w/o a trailing dot",
|
||||
rawText: "Joe went to http://example.com.",
|
||||
markup: '<p>Joe went to <a href="http://example.com">http://example.com</a>.</p>'
|
||||
},
|
||||
// XXX several more tests like this we could port from Autolinkify.js
|
||||
// see https://bugzilla.mozilla.org/show_bug.cgi?id=1186254
|
||||
{
|
||||
desc: "should exclude invalid chars after domain part",
|
||||
rawText: "Joe went to http://www.example.com's today",
|
||||
markup: '<p>Joe went to <a href="http://www.example.com">http://www.example.com</a>'s today</p>'
|
||||
},
|
||||
{
|
||||
desc: "should not linkify protocol-relative URLs",
|
||||
rawText: "//C/Programs",
|
||||
markup: "<p>//C/Programs</p>"
|
||||
},
|
||||
// do a few tests to convince ourselves that, when our code is handled
|
||||
// HTML in the input box, the integration of our code with React should
|
||||
// cause that to rendered to appear as HTML source code, rather than
|
||||
// getting injected into our real HTML DOM
|
||||
{
|
||||
desc: "should linkify simple HTML include an href properly escaped",
|
||||
rawText: '<p>Joe went to <a href="http://www.example.com">example</a></p>',
|
||||
markup: '<p><p>Joe went to <a href="<a href="http://www.example.com">http://www.example.com</a>">example</a></p></p>'
|
||||
},
|
||||
{
|
||||
desc: "should linkify HTML with nested tags and resource path properly escaped",
|
||||
rawText: '<a href="http://example.com"><img src="http://example.com" /></a>',
|
||||
markup: '<p><a href="<a href="http://example.com">http://example.com</a>"><img src="<a href="http://example.com">http://example.com</a>" /></a></p>'
|
||||
}
|
||||
];
|
||||
|
||||
var skippedTests = [
|
||||
|
||||
// XXX lots of tests similar to below we could port:
|
||||
// see https://bugzilla.mozilla.org/show_bug.cgi?id=1186254
|
||||
{
|
||||
desc: "should link localhost URLs with an allowed URL scheme",
|
||||
rawText: "Joe went to http://localhost today",
|
||||
markup: '<p>Joe went to <a href="http://localhost">localhost</a></p> today'
|
||||
},
|
||||
// XXX lots of tests similar to below we could port:
|
||||
// see https://bugzilla.mozilla.org/show_bug.cgi?id=1186254
|
||||
{
|
||||
desc: "should not include a ? if at the end of a URL",
|
||||
rawText: "Did Joe go to http://example.com?",
|
||||
markup: '<p>Did Joe go to <a href="http://example.com">http://example.com</a>?</p>'
|
||||
},
|
||||
{
|
||||
desc: "should linkify 'check out http://example.com/monkey.', w/o trailing dots",
|
||||
rawText: "check out http://example.com/monkey...",
|
||||
markup: '<p>check out <a href="http://example.com/monkey">http://example.com/monkey</a>...</p>'
|
||||
},
|
||||
// another variant of eating too many trailing characters, it includes
|
||||
// the trailing ", which it shouldn't. Makes links inside pasted HTML
|
||||
// source be slightly broken. Not key for our target users, I suspect,
|
||||
// but still...
|
||||
{
|
||||
desc: "should linkify HTML with nested tags and a resource path properly escaped",
|
||||
rawText: '<a href="http://example.com"><img src="http://example.com/someImage.jpg" /></a>',
|
||||
markup: '<p><a href="<a href="http://example.com">http://example.com</a>"><img src="<a href="http://example.com/someImage.jpg"">http://example.com/someImage.jpg"</a> /></a></p>'
|
||||
},
|
||||
// XXX handle domains without schemes (bug 1186245)
|
||||
// see https://bugzilla.mozilla.org/show_bug.cgi?id=1186254
|
||||
{
|
||||
desc: "should linkify a.museum (known TLD), but not abc.qqqq",
|
||||
rawText: "a.museum should be linked, but abc.qqqq should not",
|
||||
markup: '<p><a href="http://a.museum">a.museum</a> should be linked, but abc.qqqq should not</p>'
|
||||
},
|
||||
{
|
||||
desc: "should linkify example.xyz (known TLD), but not example.etc (unknown TLD)",
|
||||
rawText: "example.xyz should be linked, example.etc should not",
|
||||
rawMarkup: '<><a href="http://example.xyz">example.xyz</a> should be linked, example.etc should not</p>'
|
||||
}
|
||||
];
|
||||
|
||||
tests.forEach(testRender);
|
||||
|
||||
// XXX Over time, we'll want to make these pass..
|
||||
// see https://bugzilla.mozilla.org/show_bug.cgi?id=1186254
|
||||
|
||||
skippedTests.forEach(testSkip);
|
||||
|
||||
});
|
||||
});
|
||||
});
|
|
@ -250,6 +250,21 @@ describe("loop.shared.views.TextChatView", function () {
|
|||
|
||||
expect(node.querySelector(".text-chat-entry-timestamp")).to.not.eql(null);
|
||||
});
|
||||
|
||||
// note that this is really an integration test to be sure that we don't
|
||||
// inadvertently regress using LinkifiedTextView.
|
||||
it("should linkify a URL starting with http", function (){
|
||||
view = mountTestComponent({
|
||||
showTimestamp: true,
|
||||
timestamp: "2015-06-23T22:48:39.738Z",
|
||||
type: CHAT_MESSAGE_TYPES.RECEIVED,
|
||||
contentType: CHAT_CONTENT_TYPES.TEXT,
|
||||
message: "Check out http://example.com and see what you think..."
|
||||
});
|
||||
var node = view.getDOMNode();
|
||||
|
||||
expect(node.querySelector("a")).to.not.eql(null);
|
||||
});
|
||||
});
|
||||
|
||||
describe("TextChatView", function() {
|
||||
|
|
|
@ -58,6 +58,8 @@
|
|||
<script src="../content/shared/js/textChatStore.js"></script>
|
||||
<script src="../content/js/feedbackViews.js"></script>
|
||||
<script src="../content/shared/js/textChatView.js"></script>
|
||||
<script src="../content/shared/js/urlRegExps.js"></script>
|
||||
<script src="../content/shared/js/linkifiedTextView.js"></script>
|
||||
<script src="../content/js/roomStore.js"></script>
|
||||
<script src="../content/js/roomViews.js"></script>
|
||||
<script src="../content/js/conversationViews.js"></script>
|
||||
|
|
|
@ -86,9 +86,11 @@
|
|||
// Dummy function to stop warnings.
|
||||
},
|
||||
|
||||
sendTextChatMessage: function(message) {
|
||||
sendTextChatMessage: function(actionData) {
|
||||
dispatcher.dispatch(new loop.shared.actions.ReceivedTextChatMessage({
|
||||
message: message.message
|
||||
contentType: loop.store.CHAT_CONTENT_TYPES.TEXT,
|
||||
message: actionData.message,
|
||||
receivedTimestamp: actionData.sentTimestamp
|
||||
}));
|
||||
}
|
||||
};
|
||||
|
@ -394,16 +396,17 @@
|
|||
message: "Rheet!",
|
||||
sentTimestamp: "2015-06-23T22:21:45.590Z"
|
||||
}));
|
||||
dispatcher.dispatch(new sharedActions.ReceivedTextChatMessage({
|
||||
contentType: loop.store.CHAT_CONTENT_TYPES.TEXT,
|
||||
message: "Hi there",
|
||||
receivedTimestamp: "2015-06-23T22:21:45.590Z"
|
||||
}));
|
||||
dispatcher.dispatch(new sharedActions.ReceivedTextChatMessage({
|
||||
contentType: loop.store.CHAT_CONTENT_TYPES.TEXT,
|
||||
message: "Hello",
|
||||
receivedTimestamp: "2015-06-23T23:24:45.590Z"
|
||||
}));
|
||||
dispatcher.dispatch(new sharedActions.SendTextChatMessage({
|
||||
contentType: loop.store.CHAT_CONTENT_TYPES.TEXT,
|
||||
message: "Nowforareallylongwordwithoutspacesorpunctuationwhichshouldcause" +
|
||||
"linewrappingissuesifthecssiswrong",
|
||||
sentTimestamp: "2015-06-23T22:23:45.590Z"
|
||||
}));
|
||||
dispatcher.dispatch(new sharedActions.SendTextChatMessage({
|
||||
contentType: loop.store.CHAT_CONTENT_TYPES.TEXT,
|
||||
message: "Check out this menu from DNA Pizza:" +
|
||||
|
@ -411,12 +414,6 @@
|
|||
"%8D%E0%B8%88%E0%B8%A1%E0%B8%A3%E0%8D%E0%B8%88%E0%B8%A1%E0%B8%A3%E0%",
|
||||
sentTimestamp: "2015-06-23T22:23:45.590Z"
|
||||
}));
|
||||
dispatcher.dispatch(new sharedActions.SendTextChatMessage({
|
||||
contentType: loop.store.CHAT_CONTENT_TYPES.TEXT,
|
||||
message: "Nowforareallylongwordwithoutspacesorpunctuationwhichshouldcause" +
|
||||
"linewrappingissuesifthecssiswrong",
|
||||
sentTimestamp: "2015-06-23T22:23:45.590Z"
|
||||
}));
|
||||
dispatcher.dispatch(new sharedActions.ReceivedTextChatMessage({
|
||||
contentType: loop.store.CHAT_CONTENT_TYPES.TEXT,
|
||||
message: "That avocado monkey-brains pie sounds tasty!",
|
||||
|
@ -427,10 +424,10 @@
|
|||
message: "What time should we meet?",
|
||||
sentTimestamp: "2015-06-23T22:27:45.590Z"
|
||||
}));
|
||||
dispatcher.dispatch(new sharedActions.SendTextChatMessage({
|
||||
dispatcher.dispatch(new sharedActions.ReceivedTextChatMessage({
|
||||
contentType: loop.store.CHAT_CONTENT_TYPES.TEXT,
|
||||
message: "Cool",
|
||||
sentTimestamp: "2015-06-23T22:27:45.590Z"
|
||||
message: "8:00 PM",
|
||||
receivedTimestamp: "2015-06-23T22:27:45.590Z"
|
||||
}));
|
||||
|
||||
loop.store.StoreMixin.register({
|
||||
|
|
|
@ -86,9 +86,11 @@
|
|||
// Dummy function to stop warnings.
|
||||
},
|
||||
|
||||
sendTextChatMessage: function(message) {
|
||||
sendTextChatMessage: function(actionData) {
|
||||
dispatcher.dispatch(new loop.shared.actions.ReceivedTextChatMessage({
|
||||
message: message.message
|
||||
contentType: loop.store.CHAT_CONTENT_TYPES.TEXT,
|
||||
message: actionData.message,
|
||||
receivedTimestamp: actionData.sentTimestamp
|
||||
}));
|
||||
}
|
||||
};
|
||||
|
@ -394,16 +396,17 @@
|
|||
message: "Rheet!",
|
||||
sentTimestamp: "2015-06-23T22:21:45.590Z"
|
||||
}));
|
||||
dispatcher.dispatch(new sharedActions.ReceivedTextChatMessage({
|
||||
contentType: loop.store.CHAT_CONTENT_TYPES.TEXT,
|
||||
message: "Hi there",
|
||||
receivedTimestamp: "2015-06-23T22:21:45.590Z"
|
||||
}));
|
||||
dispatcher.dispatch(new sharedActions.ReceivedTextChatMessage({
|
||||
contentType: loop.store.CHAT_CONTENT_TYPES.TEXT,
|
||||
message: "Hello",
|
||||
receivedTimestamp: "2015-06-23T23:24:45.590Z"
|
||||
}));
|
||||
dispatcher.dispatch(new sharedActions.SendTextChatMessage({
|
||||
contentType: loop.store.CHAT_CONTENT_TYPES.TEXT,
|
||||
message: "Nowforareallylongwordwithoutspacesorpunctuationwhichshouldcause" +
|
||||
"linewrappingissuesifthecssiswrong",
|
||||
sentTimestamp: "2015-06-23T22:23:45.590Z"
|
||||
}));
|
||||
dispatcher.dispatch(new sharedActions.SendTextChatMessage({
|
||||
contentType: loop.store.CHAT_CONTENT_TYPES.TEXT,
|
||||
message: "Check out this menu from DNA Pizza:" +
|
||||
|
@ -411,12 +414,6 @@
|
|||
"%8D%E0%B8%88%E0%B8%A1%E0%B8%A3%E0%8D%E0%B8%88%E0%B8%A1%E0%B8%A3%E0%",
|
||||
sentTimestamp: "2015-06-23T22:23:45.590Z"
|
||||
}));
|
||||
dispatcher.dispatch(new sharedActions.SendTextChatMessage({
|
||||
contentType: loop.store.CHAT_CONTENT_TYPES.TEXT,
|
||||
message: "Nowforareallylongwordwithoutspacesorpunctuationwhichshouldcause" +
|
||||
"linewrappingissuesifthecssiswrong",
|
||||
sentTimestamp: "2015-06-23T22:23:45.590Z"
|
||||
}));
|
||||
dispatcher.dispatch(new sharedActions.ReceivedTextChatMessage({
|
||||
contentType: loop.store.CHAT_CONTENT_TYPES.TEXT,
|
||||
message: "That avocado monkey-brains pie sounds tasty!",
|
||||
|
@ -427,10 +424,10 @@
|
|||
message: "What time should we meet?",
|
||||
sentTimestamp: "2015-06-23T22:27:45.590Z"
|
||||
}));
|
||||
dispatcher.dispatch(new sharedActions.SendTextChatMessage({
|
||||
dispatcher.dispatch(new sharedActions.ReceivedTextChatMessage({
|
||||
contentType: loop.store.CHAT_CONTENT_TYPES.TEXT,
|
||||
message: "Cool",
|
||||
sentTimestamp: "2015-06-23T22:27:45.590Z"
|
||||
message: "8:00 PM",
|
||||
receivedTimestamp: "2015-06-23T22:27:45.590Z"
|
||||
}));
|
||||
|
||||
loop.store.StoreMixin.register({
|
||||
|
|
|
@ -9,6 +9,7 @@ DIRS += [
|
|||
'customizableui',
|
||||
'dirprovider',
|
||||
'downloads',
|
||||
'extensions',
|
||||
'feeds',
|
||||
'loop',
|
||||
'migration',
|
||||
|
@ -16,7 +17,6 @@ DIRS += [
|
|||
'pocket',
|
||||
'preferences',
|
||||
'privatebrowsing',
|
||||
'readinglist',
|
||||
'search',
|
||||
'sessionstore',
|
||||
'shell',
|
||||
|
|
|
@ -169,6 +169,9 @@ XPCOMUtils.defineLazyModuleGetter(this, "AddonWatcher",
|
|||
XPCOMUtils.defineLazyModuleGetter(this, "LightweightThemeManager",
|
||||
"resource://gre/modules/LightweightThemeManager.jsm");
|
||||
|
||||
XPCOMUtils.defineLazyModuleGetter(this, "ExtensionManagement",
|
||||
"resource://gre/modules/ExtensionManagement.jsm");
|
||||
|
||||
const PREF_PLUGINS_NOTIFYUSER = "plugins.update.notifyUser";
|
||||
const PREF_PLUGINS_UPDATEURL = "plugins.update.url";
|
||||
|
||||
|
@ -601,6 +604,12 @@ BrowserGlue.prototype = {
|
|||
os.addObserver(this, "xpi-signature-changed", false);
|
||||
os.addObserver(this, "autocomplete-did-enter-text", false);
|
||||
|
||||
ExtensionManagement.registerScript("chrome://browser/content/ext-utils.js");
|
||||
ExtensionManagement.registerScript("chrome://browser/content/ext-browserAction.js");
|
||||
ExtensionManagement.registerScript("chrome://browser/content/ext-contextMenus.js");
|
||||
ExtensionManagement.registerScript("chrome://browser/content/ext-tabs.js");
|
||||
ExtensionManagement.registerScript("chrome://browser/content/ext-windows.js");
|
||||
|
||||
this._flashHangCount = 0;
|
||||
},
|
||||
|
||||
|
|
|
@ -18,9 +18,8 @@ XPCOMUtils.defineLazyModuleGetter(this, "fxaMigrator",
|
|||
const PAGE_NO_ACCOUNT = 0;
|
||||
const PAGE_HAS_ACCOUNT = 1;
|
||||
const PAGE_NEEDS_UPDATE = 2;
|
||||
const PAGE_PLEASE_WAIT = 3;
|
||||
const FXA_PAGE_LOGGED_OUT = 4;
|
||||
const FXA_PAGE_LOGGED_IN = 5;
|
||||
const FXA_PAGE_LOGGED_OUT = 3;
|
||||
const FXA_PAGE_LOGGED_IN = 4;
|
||||
|
||||
// Indexes into the "login status" deck.
|
||||
// We are in a successful verified state - everything should work!
|
||||
|
@ -68,7 +67,7 @@ let gSyncPane = {
|
|||
|
||||
// it may take some time before we can determine what provider to use
|
||||
// and the state of that provider, so show the "please wait" page.
|
||||
this.page = PAGE_PLEASE_WAIT;
|
||||
this._showLoadPage(xps);
|
||||
|
||||
let onUnload = function () {
|
||||
window.removeEventListener("unload", onUnload, false);
|
||||
|
@ -89,6 +88,30 @@ let gSyncPane = {
|
|||
xps.ensureLoaded();
|
||||
},
|
||||
|
||||
_showLoadPage: function (xps) {
|
||||
let username;
|
||||
try {
|
||||
username = Services.prefs.getCharPref("services.sync.username");
|
||||
} catch (e) {}
|
||||
if (!username) {
|
||||
this.page = FXA_PAGE_LOGGED_OUT;
|
||||
} else if (xps.fxAccountsEnabled) {
|
||||
// Use cached values while we wait for the up-to-date values
|
||||
let cachedComputerName;
|
||||
try {
|
||||
cachedComputerName = Services.prefs.getCharPref("services.sync.client.name");
|
||||
}
|
||||
catch (e) {
|
||||
cachedComputerName = "";
|
||||
}
|
||||
document.getElementById("fxaEmailAddress1").textContent = username;
|
||||
document.getElementById("fxaSyncComputerName").value = cachedComputerName;
|
||||
this.page = FXA_PAGE_LOGGED_IN;
|
||||
} else { // Old Sync
|
||||
this.page = PAGE_HAS_ACCOUNT;
|
||||
}
|
||||
},
|
||||
|
||||
_init: function () {
|
||||
let topics = ["weave:service:login:error",
|
||||
"weave:service:login:finish",
|
||||
|
@ -295,19 +318,13 @@ let gSyncPane = {
|
|||
fxaEmailAddress1Label.hidden = false;
|
||||
displayNameLabel.hidden = true;
|
||||
|
||||
// unhide the reading-list engine if readinglist is enabled (note we do
|
||||
// it here as it must remain disabled for legacy sync users)
|
||||
if (Services.prefs.getBoolPref("browser.readinglist.enabled")) {
|
||||
document.getElementById("readinglist-engine").removeAttribute("hidden");
|
||||
}
|
||||
|
||||
let profileInfoEnabled;
|
||||
try {
|
||||
profileInfoEnabled = Services.prefs.getBoolPref("identity.fxaccounts.profile_image.enabled");
|
||||
} catch (ex) {}
|
||||
|
||||
// determine the fxa status...
|
||||
this.page = PAGE_PLEASE_WAIT;
|
||||
this._showLoadPage(service);
|
||||
|
||||
fxAccounts.getSignedInUser().then(data => {
|
||||
if (!data) {
|
||||
|
|
|
@ -24,10 +24,6 @@
|
|||
<preference id="engine.passwords"
|
||||
name="services.sync.engine.passwords"
|
||||
type="bool"/>
|
||||
<!-- non Sync-Engine engines -->
|
||||
<preference id="engine.readinglist"
|
||||
name="readinglist.scheduler.enabled"
|
||||
type="bool"/>
|
||||
</preferences>
|
||||
|
||||
<script type="application/javascript"
|
||||
|
@ -204,12 +200,6 @@
|
|||
</vbox>
|
||||
|
||||
<!-- These panels are for the Firefox Accounts identity provider -->
|
||||
<vbox id="fxaDeterminingStatus" align="center">
|
||||
<spacer flex="1"/>
|
||||
<label>&determiningAcctStatus.label;</label>
|
||||
<spacer flex="1"/>
|
||||
</vbox>
|
||||
|
||||
<vbox id="noFxaAccount" align="start">
|
||||
<label>&welcome.description;</label>
|
||||
<label id="noFxaSignUp" class="text-link">
|
||||
|
@ -306,11 +296,6 @@
|
|||
<checkbox label="&engine.history.label;"
|
||||
accesskey="&engine.history.accesskey;"
|
||||
preference="engine.history"/>
|
||||
<checkbox id="readinglist-engine"
|
||||
label="&engine.readinglist.label;"
|
||||
accesskey="&engine.readinglist.accesskey;"
|
||||
preference="engine.readinglist"
|
||||
hidden="true"/>
|
||||
<checkbox label="&engine.addons.label;"
|
||||
accesskey="&engine.addons.accesskey;"
|
||||
preference="engine.addons"/>
|
||||
|
|
|
@ -156,11 +156,6 @@ let gSyncPane = {
|
|||
// service.fxAccountsEnabled is false iff sync is already configured for
|
||||
// the legacy provider.
|
||||
if (service.fxAccountsEnabled) {
|
||||
// unhide the reading-list engine if readinglist is enabled (note we do
|
||||
// it here as it must remain disabled for legacy sync users)
|
||||
if (Services.prefs.getBoolPref("browser.readinglist.enabled")) {
|
||||
document.getElementById("readinglist-engine").removeAttribute("hidden");
|
||||
}
|
||||
// determine the fxa status...
|
||||
this.page = PAGE_PLEASE_WAIT;
|
||||
fxAccounts.getSignedInUser().then(data => {
|
||||
|
|
|
@ -28,8 +28,6 @@
|
|||
<preference id="engine.tabs" name="services.sync.engine.tabs" type="bool"/>
|
||||
<preference id="engine.prefs" name="services.sync.engine.prefs" type="bool"/>
|
||||
<preference id="engine.passwords" name="services.sync.engine.passwords" type="bool"/>
|
||||
<!-- non Sync-Engine engines -->
|
||||
<preference id="engine.readinglist" name="readinglist.scheduler.enabled" type="bool"/>
|
||||
</preferences>
|
||||
|
||||
|
||||
|
@ -301,12 +299,6 @@
|
|||
accesskey="&engine.history.accesskey;"
|
||||
onsynctopreference="gSyncPane.onPreferenceChanged(this);"
|
||||
preference="engine.history"/>
|
||||
<!-- onpreferencechanged not needed for the readinglist engine -->
|
||||
<checkbox id="readinglist-engine"
|
||||
label="&engine.readinglist.label;"
|
||||
accesskey="&engine.readinglist.accesskey;"
|
||||
preference="engine.readinglist"
|
||||
hidden="true"/>
|
||||
<checkbox label="&engine.addons.label;"
|
||||
accesskey="&engine.addons.accesskey;"
|
||||
onsynctopreference="gSyncPane.onPreferenceChanged();"
|
||||
|
|
Разница между файлами не показана из-за своего большого размера
Загрузить разницу
|
@ -1,466 +0,0 @@
|
|||
/* 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 = [
|
||||
"SQLiteStore",
|
||||
];
|
||||
|
||||
const { classes: Cc, interfaces: Ci, utils: Cu } = Components;
|
||||
|
||||
Cu.import("resource://gre/modules/Task.jsm");
|
||||
Cu.import("resource://gre/modules/XPCOMUtils.jsm");
|
||||
|
||||
XPCOMUtils.defineLazyModuleGetter(this, "ReadingList",
|
||||
"resource:///modules/readinglist/ReadingList.jsm");
|
||||
XPCOMUtils.defineLazyModuleGetter(this, "Sqlite",
|
||||
"resource://gre/modules/Sqlite.jsm");
|
||||
|
||||
/**
|
||||
* A SQLite Reading List store backed by a database on disk. The database is
|
||||
* created if it doesn't exist.
|
||||
*
|
||||
* @param pathRelativeToProfileDir The path of the database file relative to
|
||||
* the profile directory.
|
||||
*/
|
||||
this.SQLiteStore = function SQLiteStore(pathRelativeToProfileDir) {
|
||||
this.pathRelativeToProfileDir = pathRelativeToProfileDir;
|
||||
};
|
||||
|
||||
this.SQLiteStore.prototype = {
|
||||
|
||||
/**
|
||||
* Yields the number of items in the store that match the given options.
|
||||
*
|
||||
* @param userOptsList A variable number of options objects that control the
|
||||
* items that are matched. See Options Objects in ReadingList.jsm.
|
||||
* @param controlOpts A single options object. Use this to filter out items
|
||||
* that don't match it -- in other words, to override the user options.
|
||||
* See Options Objects in ReadingList.jsm.
|
||||
* @return Promise<number> The number of matching items in the store.
|
||||
* Rejected with an Error on error.
|
||||
*/
|
||||
count: Task.async(function* (userOptsList=[], controlOpts={}) {
|
||||
let [sql, args] = sqlWhereFromOptions(userOptsList, controlOpts);
|
||||
let count = 0;
|
||||
let conn = yield this._connectionPromise;
|
||||
yield conn.executeCached(`
|
||||
SELECT COUNT(*) AS count FROM items ${sql};
|
||||
`, args, row => count = row.getResultByName("count"));
|
||||
return count;
|
||||
}),
|
||||
|
||||
/**
|
||||
* Enumerates the items in the store that match the given options.
|
||||
*
|
||||
* @param callback Called for each item in the enumeration. It's passed a
|
||||
* single object, an item.
|
||||
* @param userOptsList A variable number of options objects that control the
|
||||
* items that are matched. See Options Objects in ReadingList.jsm.
|
||||
* @param controlOpts A single options object. Use this to filter out items
|
||||
* that don't match it -- in other words, to override the user options.
|
||||
* See Options Objects in ReadingList.jsm.
|
||||
* @return Promise<null> Resolved when the enumeration completes. Rejected
|
||||
* with an Error on error.
|
||||
*/
|
||||
forEachItem: Task.async(function* (callback, userOptsList=[], controlOpts={}) {
|
||||
let [sql, args] = sqlWhereFromOptions(userOptsList, controlOpts);
|
||||
let colNames = ReadingList.ItemRecordProperties;
|
||||
let conn = yield this._connectionPromise;
|
||||
yield conn.executeCached(`
|
||||
SELECT ${colNames} FROM items ${sql};
|
||||
`, args, row => callback(itemFromRow(row)));
|
||||
}),
|
||||
|
||||
/**
|
||||
* Adds an item to the store that isn't already present. See
|
||||
* ReadingList.prototype.addItem.
|
||||
*
|
||||
* @param items A simple object representing an item.
|
||||
* @return Promise<null> Resolved when the store is updated. Rejected with an
|
||||
* Error on error.
|
||||
*/
|
||||
addItem: Task.async(function* (item) {
|
||||
let colNames = [];
|
||||
let paramNames = [];
|
||||
for (let propName in item) {
|
||||
colNames.push(propName);
|
||||
paramNames.push(`:${propName}`);
|
||||
}
|
||||
let conn = yield this._connectionPromise;
|
||||
try {
|
||||
yield conn.executeCached(`
|
||||
INSERT INTO items (${colNames}) VALUES (${paramNames});
|
||||
`, item);
|
||||
}
|
||||
catch (err) {
|
||||
throwExistsError(err);
|
||||
}
|
||||
}),
|
||||
|
||||
/**
|
||||
* Updates the properties of an item that's already present in the store. See
|
||||
* ReadingList.prototype.updateItem.
|
||||
*
|
||||
* @param item The item to update. It must have a `url`.
|
||||
* @return Promise<null> Resolved when the store is updated. Rejected with an
|
||||
* Error on error.
|
||||
*/
|
||||
updateItem: Task.async(function* (item) {
|
||||
yield this._updateItem(item, "url");
|
||||
}),
|
||||
|
||||
/**
|
||||
* Same as updateItem, but the item is keyed off of its `guid` instead of its
|
||||
* `url`.
|
||||
*
|
||||
* @param item The item to update. It must have a `guid`.
|
||||
* @return Promise<null> Resolved when the store is updated. Rejected with an
|
||||
* Error on error.
|
||||
*/
|
||||
updateItemByGUID: Task.async(function* (item) {
|
||||
yield this._updateItem(item, "guid");
|
||||
}),
|
||||
|
||||
/**
|
||||
* Deletes an item from the store by its URL.
|
||||
*
|
||||
* @param url The URL string of the item to delete.
|
||||
* @return Promise<null> Resolved when the store is updated. Rejected with an
|
||||
* Error on error.
|
||||
*/
|
||||
deleteItemByURL: Task.async(function* (url) {
|
||||
let conn = yield this._connectionPromise;
|
||||
yield conn.executeCached(`
|
||||
DELETE FROM items WHERE url = :url;
|
||||
`, { url: url });
|
||||
}),
|
||||
|
||||
/**
|
||||
* Deletes an item from the store by its GUID.
|
||||
*
|
||||
* @param guid The GUID string of the item to delete.
|
||||
* @return Promise<null> Resolved when the store is updated. Rejected with an
|
||||
* Error on error.
|
||||
*/
|
||||
deleteItemByGUID: Task.async(function* (guid) {
|
||||
let conn = yield this._connectionPromise;
|
||||
yield conn.executeCached(`
|
||||
DELETE FROM items WHERE guid = :guid;
|
||||
`, { guid: guid });
|
||||
}),
|
||||
|
||||
/**
|
||||
* Call this when you're done with the store. Don't use it afterward.
|
||||
*/
|
||||
destroy() {
|
||||
if (!this._destroyPromise) {
|
||||
this._destroyPromise = Task.spawn(function* () {
|
||||
let conn = yield this._connectionPromise;
|
||||
yield conn.close();
|
||||
this.__connectionPromise = Promise.reject("Store destroyed");
|
||||
}.bind(this));
|
||||
}
|
||||
return this._destroyPromise;
|
||||
},
|
||||
|
||||
/**
|
||||
* Promise<Sqlite.OpenedConnection>
|
||||
*/
|
||||
get _connectionPromise() {
|
||||
if (!this.__connectionPromise) {
|
||||
this.__connectionPromise = this._createConnection();
|
||||
}
|
||||
return this.__connectionPromise;
|
||||
},
|
||||
|
||||
/**
|
||||
* Creates the database connection.
|
||||
*
|
||||
* @return Promise<Sqlite.OpenedConnection>
|
||||
*/
|
||||
_createConnection: Task.async(function* () {
|
||||
let conn = yield Sqlite.openConnection({
|
||||
path: this.pathRelativeToProfileDir,
|
||||
sharedMemoryCache: false,
|
||||
});
|
||||
Sqlite.shutdown.addBlocker("readinglist/SQLiteStore: Destroy",
|
||||
this.destroy.bind(this));
|
||||
yield conn.execute(`
|
||||
PRAGMA locking_mode = EXCLUSIVE;
|
||||
`);
|
||||
yield this._checkSchema(conn);
|
||||
return conn;
|
||||
}),
|
||||
|
||||
/**
|
||||
* Updates the properties of an item that's already present in the store. See
|
||||
* ReadingList.prototype.updateItem.
|
||||
*
|
||||
* @param item The item to update. It must have the property named by
|
||||
* keyProp.
|
||||
* @param keyProp The item is keyed off of this property.
|
||||
* @return Promise<null> Resolved when the store is updated. Rejected with an
|
||||
* Error on error.
|
||||
*/
|
||||
_updateItem: Task.async(function* (item, keyProp) {
|
||||
let assignments = [];
|
||||
for (let propName in item) {
|
||||
assignments.push(`${propName} = :${propName}`);
|
||||
}
|
||||
let conn = yield this._connectionPromise;
|
||||
if (!item[keyProp]) {
|
||||
throw new ReadingList.Error.Error("Item must have " + keyProp);
|
||||
}
|
||||
try {
|
||||
yield conn.executeCached(`
|
||||
UPDATE items SET ${assignments} WHERE ${keyProp} = :${keyProp};
|
||||
`, item);
|
||||
}
|
||||
catch (err) {
|
||||
throwExistsError(err);
|
||||
}
|
||||
}),
|
||||
|
||||
// The current schema version.
|
||||
_schemaVersion: 1,
|
||||
|
||||
_checkSchema: Task.async(function* (conn) {
|
||||
let version = parseInt(yield conn.getSchemaVersion());
|
||||
for (; version < this._schemaVersion; version++) {
|
||||
let meth = `_migrateSchema${version}To${version + 1}`;
|
||||
yield this[meth](conn);
|
||||
}
|
||||
yield conn.setSchemaVersion(this._schemaVersion);
|
||||
}),
|
||||
|
||||
_migrateSchema0To1: Task.async(function* (conn) {
|
||||
yield conn.execute(`
|
||||
PRAGMA journal_mode = wal;
|
||||
`);
|
||||
// 524288 bytes = 512 KiB
|
||||
yield conn.execute(`
|
||||
PRAGMA journal_size_limit = 524288;
|
||||
`);
|
||||
// Not important, but FYI: The order that these columns are listed in
|
||||
// follows the order that the server doc lists the fields in the article
|
||||
// data model, more or less:
|
||||
// http://readinglist.readthedocs.org/en/latest/model.html
|
||||
yield conn.execute(`
|
||||
CREATE TABLE items (
|
||||
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
||||
guid TEXT UNIQUE,
|
||||
serverLastModified INTEGER,
|
||||
url TEXT UNIQUE,
|
||||
preview TEXT,
|
||||
title TEXT,
|
||||
resolvedURL TEXT UNIQUE,
|
||||
resolvedTitle TEXT,
|
||||
excerpt TEXT,
|
||||
archived BOOLEAN,
|
||||
deleted BOOLEAN,
|
||||
favorite BOOLEAN,
|
||||
isArticle BOOLEAN,
|
||||
wordCount INTEGER,
|
||||
unread BOOLEAN,
|
||||
addedBy TEXT,
|
||||
addedOn INTEGER,
|
||||
storedOn INTEGER,
|
||||
markedReadBy TEXT,
|
||||
markedReadOn INTEGER,
|
||||
readPosition INTEGER,
|
||||
syncStatus INTEGER
|
||||
);
|
||||
`);
|
||||
yield conn.execute(`
|
||||
CREATE INDEX items_addedOn ON items (addedOn);
|
||||
`);
|
||||
yield conn.execute(`
|
||||
CREATE INDEX items_unread ON items (unread);
|
||||
`);
|
||||
}),
|
||||
};
|
||||
|
||||
/**
|
||||
* Returns a simple object whose properties are the
|
||||
* ReadingList.ItemRecordProperties lifted from the given row.
|
||||
*
|
||||
* @param row A mozIStorageRow.
|
||||
* @return The item.
|
||||
*/
|
||||
function itemFromRow(row) {
|
||||
let item = {};
|
||||
for (let name of ReadingList.ItemRecordProperties) {
|
||||
item[name] = row.getResultByName(name);
|
||||
}
|
||||
return item;
|
||||
}
|
||||
|
||||
/**
|
||||
* If the given Error indicates that a unique constraint failed, then wraps that
|
||||
* error in a ReadingList.Error.Exists and throws it. Otherwise throws the
|
||||
* given error.
|
||||
*
|
||||
* @param err An Error object.
|
||||
*/
|
||||
function throwExistsError(err) {
|
||||
let match =
|
||||
/UNIQUE constraint failed: items\.([a-zA-Z0-9_]+)/.exec(err.message);
|
||||
if (match) {
|
||||
let newErr = new ReadingList.Error.Exists(
|
||||
"An item with the following property already exists: " + match[1]
|
||||
);
|
||||
newErr.originalError = err;
|
||||
err = newErr;
|
||||
}
|
||||
throw err;
|
||||
}
|
||||
|
||||
/**
|
||||
* Returns the back part of a SELECT statement generated from the given list of
|
||||
* options.
|
||||
*
|
||||
* @param userOptsList A variable number of options objects that control the
|
||||
* items that are matched. See Options Objects in ReadingList.jsm.
|
||||
* @param controlOpts A single options object. Use this to filter out items
|
||||
* that don't match it -- in other words, to override the user options.
|
||||
* See Options Objects in ReadingList.jsm.
|
||||
* @return An array [sql, args]. sql is a string of SQL. args is an object
|
||||
* that contains arguments for all the parameters in sql.
|
||||
*/
|
||||
function sqlWhereFromOptions(userOptsList, controlOpts) {
|
||||
// We modify the options objects in userOptsList, which were passed in by the
|
||||
// store client, so clone them first.
|
||||
userOptsList = Cu.cloneInto(userOptsList, {}, { cloneFunctions: false });
|
||||
|
||||
let sort;
|
||||
let sortDir;
|
||||
let limit;
|
||||
let offset;
|
||||
for (let opts of userOptsList) {
|
||||
if ("sort" in opts) {
|
||||
sort = opts.sort;
|
||||
delete opts.sort;
|
||||
}
|
||||
if ("descending" in opts) {
|
||||
if (opts.descending) {
|
||||
sortDir = "DESC";
|
||||
}
|
||||
delete opts.descending;
|
||||
}
|
||||
if ("limit" in opts) {
|
||||
limit = opts.limit;
|
||||
delete opts.limit;
|
||||
}
|
||||
if ("offset" in opts) {
|
||||
offset = opts.offset;
|
||||
delete opts.offset;
|
||||
}
|
||||
}
|
||||
|
||||
let fragments = [];
|
||||
|
||||
if (sort) {
|
||||
sortDir = sortDir || "ASC";
|
||||
fragments.push(`ORDER BY ${sort} ${sortDir}`);
|
||||
}
|
||||
if (limit) {
|
||||
fragments.push(`LIMIT ${limit}`);
|
||||
if (offset) {
|
||||
fragments.push(`OFFSET ${offset}`);
|
||||
}
|
||||
}
|
||||
|
||||
let args = {};
|
||||
let mainExprs = [];
|
||||
|
||||
let controlSQLExpr = sqlExpressionFromOptions([controlOpts], args);
|
||||
if (controlSQLExpr) {
|
||||
mainExprs.push(`(${controlSQLExpr})`);
|
||||
}
|
||||
|
||||
let userSQLExpr = sqlExpressionFromOptions(userOptsList, args);
|
||||
if (userSQLExpr) {
|
||||
mainExprs.push(`(${userSQLExpr})`);
|
||||
}
|
||||
|
||||
if (mainExprs.length) {
|
||||
let conjunction = mainExprs.join(" AND ");
|
||||
fragments.unshift(`WHERE ${conjunction}`);
|
||||
}
|
||||
|
||||
let sql = fragments.join(" ");
|
||||
return [sql, args];
|
||||
}
|
||||
|
||||
/**
|
||||
* Returns a SQL expression generated from the given options list. Each options
|
||||
* object in the list generates a subexpression, and all the subexpressions are
|
||||
* OR'ed together to produce the final top-level expression. (e.g., an optsList
|
||||
* with three options objects would generate an expression like "(guid = :guid
|
||||
* OR (title = :title AND unread = :unread) OR resolvedURL = :resolvedURL)".)
|
||||
*
|
||||
* All the properties of the options objects are assumed to refer to columns in
|
||||
* the database. If they don't, your SQL query will fail.
|
||||
*
|
||||
* @param optsList See Options Objects in ReadingList.jsm.
|
||||
* @param args An object that will hold the SQL parameters. It will be
|
||||
* modified.
|
||||
* @return A string of SQL. Also, args will contain arguments for all the
|
||||
* parameters in the SQL.
|
||||
*/
|
||||
function sqlExpressionFromOptions(optsList, args) {
|
||||
let disjunctions = [];
|
||||
for (let opts of optsList) {
|
||||
let conjunctions = [];
|
||||
for (let key in opts) {
|
||||
if (Array.isArray(opts[key])) {
|
||||
// Convert arrays to IN expressions. e.g., { guid: ['a', 'b', 'c'] }
|
||||
// becomes "guid IN (:guid, :guid_1, :guid_2)". The guid_i arguments
|
||||
// are added to opts.
|
||||
let array = opts[key];
|
||||
let params = [];
|
||||
for (let i = 0; i < array.length; i++) {
|
||||
let paramName = uniqueParamName(args, key);
|
||||
params.push(`:${paramName}`);
|
||||
args[paramName] = array[i];
|
||||
}
|
||||
conjunctions.push(`${key} IN (${params})`);
|
||||
}
|
||||
else {
|
||||
let paramName = uniqueParamName(args, key);
|
||||
conjunctions.push(`${key} = :${paramName}`);
|
||||
args[paramName] = opts[key];
|
||||
}
|
||||
}
|
||||
let conjunction = conjunctions.join(" AND ");
|
||||
if (conjunction) {
|
||||
disjunctions.push(`(${conjunction})`);
|
||||
}
|
||||
}
|
||||
let disjunction = disjunctions.join(" OR ");
|
||||
return disjunction;
|
||||
}
|
||||
|
||||
/**
|
||||
* Returns a version of the given name such that it doesn't conflict with the
|
||||
* name of any property in args. e.g., if name is "foo" but args already has
|
||||
* properties named "foo", "foo1", and "foo2", then "foo3" is returned.
|
||||
*
|
||||
* @param args An object.
|
||||
* @param name The name you want to use.
|
||||
* @return A unique version of the given name.
|
||||
*/
|
||||
function uniqueParamName(args, name) {
|
||||
if (name in args) {
|
||||
for (let i = 1; ; i++) {
|
||||
let newName = `${name}_${i}`;
|
||||
if (!(newName in args)) {
|
||||
return newName;
|
||||
}
|
||||
}
|
||||
}
|
||||
return name;
|
||||
}
|
|
@ -1,409 +0,0 @@
|
|||
/* 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/XPCOMUtils.jsm");
|
||||
Cu.import("resource://gre/modules/Services.jsm");
|
||||
Cu.import('resource://gre/modules/Task.jsm');
|
||||
|
||||
|
||||
XPCOMUtils.defineLazyModuleGetter(this, 'LogManager',
|
||||
'resource://services-common/logmanager.js');
|
||||
|
||||
XPCOMUtils.defineLazyModuleGetter(this, 'Log',
|
||||
'resource://gre/modules/Log.jsm');
|
||||
|
||||
XPCOMUtils.defineLazyModuleGetter(this, 'Preferences',
|
||||
'resource://gre/modules/Preferences.jsm');
|
||||
|
||||
XPCOMUtils.defineLazyModuleGetter(this, 'setTimeout',
|
||||
'resource://gre/modules/Timer.jsm');
|
||||
XPCOMUtils.defineLazyModuleGetter(this, 'clearTimeout',
|
||||
'resource://gre/modules/Timer.jsm');
|
||||
|
||||
// The main readinglist module.
|
||||
XPCOMUtils.defineLazyModuleGetter(this, 'ReadingList',
|
||||
'resource:///modules/readinglist/ReadingList.jsm');
|
||||
|
||||
// The "engine"
|
||||
XPCOMUtils.defineLazyModuleGetter(this, 'Sync',
|
||||
'resource:///modules/readinglist/Sync.jsm');
|
||||
|
||||
// FxAccountsCommon.js doesn't use a "namespace", so create one here.
|
||||
XPCOMUtils.defineLazyGetter(this, "fxAccountsCommon", function() {
|
||||
let namespace = {};
|
||||
Cu.import("resource://gre/modules/FxAccountsCommon.js", namespace);
|
||||
return namespace;
|
||||
});
|
||||
|
||||
this.EXPORTED_SYMBOLS = ["ReadingListScheduler"];
|
||||
|
||||
// A list of "external" observer topics that may cause us to change when we
|
||||
// sync.
|
||||
const OBSERVERS = [
|
||||
// We don't sync when offline and restart when online.
|
||||
"network:offline-status-changed",
|
||||
// FxA notifications also cause us to check if we should sync.
|
||||
"fxaccounts:onverified",
|
||||
// some notifications the engine might send if we have been requested to backoff.
|
||||
"readinglist:backoff-requested",
|
||||
// request to sync now
|
||||
"readinglist:user-sync",
|
||||
|
||||
];
|
||||
|
||||
let prefs = new Preferences("readinglist.scheduler.");
|
||||
|
||||
// A helper to manage our interval values.
|
||||
let intervals = {
|
||||
// Getters for our intervals.
|
||||
_fixupIntervalPref(prefName, def) {
|
||||
// All pref values are seconds, but we return ms.
|
||||
return prefs.get(prefName, def) * 1000;
|
||||
},
|
||||
|
||||
// How long after startup do we do an initial sync?
|
||||
get initial() this._fixupIntervalPref("initial", 10), // 10 seconds.
|
||||
// Every interval after the first.
|
||||
get schedule() this._fixupIntervalPref("schedule", 2 * 60 * 60), // 2 hours
|
||||
// Initial retry after an error (exponentially backed-off to .schedule)
|
||||
get retry() this._fixupIntervalPref("retry", 2 * 60), // 2 mins
|
||||
};
|
||||
|
||||
// This is the implementation, but it's not exposed directly.
|
||||
function InternalScheduler(readingList = null) {
|
||||
// oh, I don't know what logs yet - let's guess!
|
||||
let logs = [
|
||||
"browserwindow.syncui",
|
||||
"FirefoxAccounts",
|
||||
"readinglist.api",
|
||||
"readinglist.scheduler",
|
||||
"readinglist.serverclient",
|
||||
"readinglist.sync",
|
||||
];
|
||||
|
||||
this._logManager = new LogManager("readinglist.", logs, "readinglist");
|
||||
this.log = Log.repository.getLogger("readinglist.scheduler");
|
||||
this.log.info("readinglist scheduler created.")
|
||||
this.state = this.STATE_OK;
|
||||
this.readingList = readingList || ReadingList; // hook point for tests.
|
||||
|
||||
// don't this.init() here, but instead at the module level - tests want to
|
||||
// add hooks before it is called.
|
||||
}
|
||||
|
||||
InternalScheduler.prototype = {
|
||||
// When the next scheduled sync should happen. If we can sync, there will
|
||||
// be a timer set to fire then. If we can't sync there will not be a timer,
|
||||
// but it will be set to fire then as soon as we can.
|
||||
_nextScheduledSync: null,
|
||||
// The time when the most-recent "backoff request" expires - we will never
|
||||
// schedule a new timer before this.
|
||||
_backoffUntil: 0,
|
||||
// Our current timer.
|
||||
_timer: null,
|
||||
// Our timer fires a promise - _timerRunning is true until it resolves or
|
||||
// rejects.
|
||||
_timerRunning: false,
|
||||
// Our sync engine - XXX - maybe just a callback?
|
||||
_engine: Sync,
|
||||
// Our current "error backoff" timeout. zero if no error backoff is in
|
||||
// progress and incremented after successive errors until a max is reached.
|
||||
_currentErrorBackoff: 0,
|
||||
|
||||
// Our state variable and constants.
|
||||
state: null,
|
||||
STATE_OK: "ok",
|
||||
STATE_ERROR_AUTHENTICATION: "authentication error",
|
||||
STATE_ERROR_OTHER: "other error",
|
||||
|
||||
init() {
|
||||
this.log.info("scheduler initialzing");
|
||||
this._setupRLListener();
|
||||
this._observe = this.observe.bind(this);
|
||||
for (let notification of OBSERVERS) {
|
||||
Services.obs.addObserver(this._observe, notification, false);
|
||||
}
|
||||
this._nextScheduledSync = Date.now() + intervals.initial;
|
||||
this._setupTimer();
|
||||
},
|
||||
|
||||
_setupRLListener() {
|
||||
let maybeSync = () => {
|
||||
if (this._timerRunning) {
|
||||
// If a sync is currently running it is possible it will miss the change
|
||||
// just made, so tell the timer the next sync should be 1 ms after
|
||||
// it completes (we don't use zero as that has special meaning re backoffs)
|
||||
this._maybeReschedule(1);
|
||||
} else {
|
||||
// Do the sync now.
|
||||
this._syncNow();
|
||||
}
|
||||
};
|
||||
let listener = {
|
||||
onItemAdded: maybeSync,
|
||||
onItemUpdated: maybeSync,
|
||||
onItemDeleted: maybeSync,
|
||||
}
|
||||
this.readingList.addListener(listener);
|
||||
},
|
||||
|
||||
// Note: only called by tests.
|
||||
finalize() {
|
||||
this.log.info("scheduler finalizing");
|
||||
this._clearTimer();
|
||||
for (let notification of OBSERVERS) {
|
||||
Services.obs.removeObserver(this._observe, notification);
|
||||
}
|
||||
this._observe = null;
|
||||
},
|
||||
|
||||
observe(subject, topic, data) {
|
||||
this.log.debug("observed ${}", topic);
|
||||
switch (topic) {
|
||||
case "readinglist:backoff-requested": {
|
||||
// The subject comes in as a string, a number of seconds.
|
||||
let interval = parseInt(data, 10);
|
||||
if (isNaN(interval)) {
|
||||
this.log.warn("Backoff request had non-numeric value", data);
|
||||
return;
|
||||
}
|
||||
this.log.info("Received a request to backoff for ${} seconds", interval);
|
||||
this._backoffUntil = Date.now() + interval * 1000;
|
||||
this._maybeReschedule(0);
|
||||
break;
|
||||
}
|
||||
case "readinglist:user-sync":
|
||||
this._syncNow();
|
||||
break;
|
||||
case "fxaccounts:onverified":
|
||||
// If we were in an authentication error state, reset that now.
|
||||
if (this.state == this.STATE_ERROR_AUTHENTICATION) {
|
||||
this.state = this.STATE_OK;
|
||||
}
|
||||
// and sync now.
|
||||
this._syncNow();
|
||||
break;
|
||||
|
||||
// The rest just indicate that now is probably a good time to check if
|
||||
// we can sync as normal using whatever schedule was previously set.
|
||||
default:
|
||||
break;
|
||||
}
|
||||
// When observers fire we ignore the current sync error state as the
|
||||
// notification may indicate it's been resolved.
|
||||
this._setupTimer(true);
|
||||
},
|
||||
|
||||
// Is the current error state such that we shouldn't schedule a new sync.
|
||||
_isBlockedOnError() {
|
||||
// this needs more thought...
|
||||
return this.state == this.STATE_ERROR_AUTHENTICATION;
|
||||
},
|
||||
|
||||
// canSync indicates if we can currently sync.
|
||||
_canSync(ignoreBlockingErrors = false) {
|
||||
if (!prefs.get("enabled")) {
|
||||
this.log.info("canSync=false - syncing is disabled");
|
||||
return false;
|
||||
}
|
||||
if (Services.io.offline) {
|
||||
this.log.info("canSync=false - we are offline");
|
||||
return false;
|
||||
}
|
||||
if (!ignoreBlockingErrors && this._isBlockedOnError()) {
|
||||
this.log.info("canSync=false - we are in a blocked error state", this.state);
|
||||
return false;
|
||||
}
|
||||
this.log.info("canSync=true");
|
||||
return true;
|
||||
},
|
||||
|
||||
// _setupTimer checks the current state and the environment to see when
|
||||
// we should next sync and creates the timer with the appropriate delay.
|
||||
_setupTimer(ignoreBlockingErrors = false) {
|
||||
if (!this._canSync(ignoreBlockingErrors)) {
|
||||
this._clearTimer();
|
||||
return;
|
||||
}
|
||||
if (this._timer) {
|
||||
let when = new Date(this._nextScheduledSync);
|
||||
let delay = this._nextScheduledSync - Date.now();
|
||||
this.log.info("checkStatus - already have a timer - will fire in ${delay}ms at ${when}",
|
||||
{delay, when});
|
||||
return;
|
||||
}
|
||||
if (this._timerRunning) {
|
||||
this.log.info("checkStatus - currently syncing");
|
||||
return;
|
||||
}
|
||||
// no timer and we can sync, so start a new one.
|
||||
let now = Date.now();
|
||||
let delay = Math.max(0, this._nextScheduledSync - now);
|
||||
let when = new Date(now + delay);
|
||||
this.log.info("next scheduled sync is in ${delay}ms (at ${when})", {delay, when})
|
||||
this._timer = this._setTimeout(delay);
|
||||
},
|
||||
|
||||
// Something (possibly naively) thinks the next sync should happen in
|
||||
// delay-ms. If there's a backoff in progress, ignore the requested delay
|
||||
// and use the back-off. If there's already a timer scheduled for earlier
|
||||
// than delay, let the earlier timer remain. Otherwise, use the requested
|
||||
// delay.
|
||||
_maybeReschedule(delay) {
|
||||
// If there's no delay specified and there's nothing currently scheduled,
|
||||
// it means a backoff request while the sync is actually running - there's
|
||||
// no need to do anything here - the next reschedule after the sync
|
||||
// completes will take the backoff into account.
|
||||
if (!delay && !this._nextScheduledSync) {
|
||||
this.log.debug("_maybeReschedule ignoring a backoff request while running");
|
||||
return;
|
||||
}
|
||||
let now = Date.now();
|
||||
if (!this._nextScheduledSync) {
|
||||
this._nextScheduledSync = now + delay;
|
||||
}
|
||||
// If there is something currently scheduled before the requested delay,
|
||||
// keep the existing value (eg, if we have a timer firing in 1 second, and
|
||||
// get a notification that says we should sync in 2 seconds, we keep the 1
|
||||
// second value)
|
||||
this._nextScheduledSync = Math.min(this._nextScheduledSync, now + delay);
|
||||
// But we still need to honor a backoff.
|
||||
this._nextScheduledSync = Math.max(this._nextScheduledSync, this._backoffUntil);
|
||||
// And always create a new timer next time _setupTimer is called.
|
||||
this._clearTimer();
|
||||
},
|
||||
|
||||
// callback for when the timer fires.
|
||||
_doSync() {
|
||||
this.log.debug("starting sync");
|
||||
this._timer = null;
|
||||
this._timerRunning = true;
|
||||
// flag that there's no new schedule yet, so a request coming in while
|
||||
// we are running does the right thing.
|
||||
this._nextScheduledSync = 0;
|
||||
Services.obs.notifyObservers(null, "readinglist:sync:start", null);
|
||||
this._engine.start().then(() => {
|
||||
this.log.info("Sync completed successfully");
|
||||
// Write a pref in the same format used to services/sync to indicate
|
||||
// the last success.
|
||||
prefs.set("lastSync", new Date().toString());
|
||||
this.state = this.STATE_OK;
|
||||
this._logManager.resetFileLog().then(result => {
|
||||
if (result == this._logManager.ERROR_LOG_WRITTEN) {
|
||||
Cu.reportError("Reading List sync encountered an error - see about:sync-log for the log file.");
|
||||
}
|
||||
});
|
||||
Services.obs.notifyObservers(null, "readinglist:sync:finish", null);
|
||||
this._currentErrorBackoff = 0; // error retry interval is reset on success.
|
||||
return intervals.schedule;
|
||||
}).catch(err => {
|
||||
// This isn't ideal - we really should have _canSync() check this - but
|
||||
// that requires a refactor to turn _canSync() into a promise-based
|
||||
// function.
|
||||
if (err.message == fxAccountsCommon.ERROR_NO_ACCOUNT ||
|
||||
err.message == fxAccountsCommon.ERROR_UNVERIFIED_ACCOUNT) {
|
||||
// make everything look like success.
|
||||
this._currentErrorBackoff = 0; // error retry interval is reset on success.
|
||||
this.log.info("Can't sync due to FxA account state " + err.message);
|
||||
this.state = this.STATE_OK;
|
||||
this._logManager.resetFileLog().then(result => {
|
||||
if (result == this._logManager.ERROR_LOG_WRITTEN) {
|
||||
Cu.reportError("Reading List sync encountered an error - see about:sync-log for the log file.");
|
||||
}
|
||||
});
|
||||
Services.obs.notifyObservers(null, "readinglist:sync:finish", null);
|
||||
// it's unfortunate that we are probably going to hit this every
|
||||
// 2 hours, but it should be invisible to the user.
|
||||
return intervals.schedule;
|
||||
}
|
||||
this.state = err.message == fxAccountsCommon.ERROR_AUTH_ERROR ?
|
||||
this.STATE_ERROR_AUTHENTICATION : this.STATE_ERROR_OTHER;
|
||||
this.log.error("Sync failed, now in state '${state}': ${err}",
|
||||
{state: this.state, err});
|
||||
this._logManager.resetFileLog();
|
||||
Services.obs.notifyObservers(null, "readinglist:sync:error", null);
|
||||
// We back-off on error retries until it hits our normally scheduled interval.
|
||||
this._currentErrorBackoff = this._currentErrorBackoff == 0 ? intervals.retry :
|
||||
Math.min(intervals.schedule, this._currentErrorBackoff * 2);
|
||||
return this._currentErrorBackoff;
|
||||
}).then(nextDelay => {
|
||||
this._timerRunning = false;
|
||||
// ensure a new timer is setup for the appropriate next time.
|
||||
this._maybeReschedule(nextDelay);
|
||||
this._setupTimer();
|
||||
this._onAutoReschedule(); // just for tests...
|
||||
}).catch(err => {
|
||||
// We should never get here, but better safe than sorry...
|
||||
this.log.error("Failed to reschedule after sync completed", err);
|
||||
});
|
||||
},
|
||||
|
||||
_clearTimer() {
|
||||
if (this._timer) {
|
||||
clearTimeout(this._timer);
|
||||
this._timer = null;
|
||||
}
|
||||
},
|
||||
|
||||
// A function to "sync now", but not allowing it to start if one is
|
||||
// already running, and rescheduling the timer.
|
||||
// To call this, just send a "readinglist:user-sync" notification.
|
||||
_syncNow() {
|
||||
if (!prefs.get("enabled")) {
|
||||
this.log.info("syncNow() but syncing is disabled - ignoring");
|
||||
return;
|
||||
}
|
||||
|
||||
if (this._timerRunning) {
|
||||
this.log.info("syncNow() but a sync is already in progress - ignoring");
|
||||
return;
|
||||
}
|
||||
this._clearTimer();
|
||||
this._doSync();
|
||||
},
|
||||
|
||||
// A couple of hook-points for testing.
|
||||
// xpcshell tests hook this so (a) it can check the expected delay is set
|
||||
// and (b) to ignore the delay and set a timeout of 0 so the test is fast.
|
||||
_setTimeout(delay) {
|
||||
return setTimeout(() => this._doSync(), delay);
|
||||
},
|
||||
// xpcshell tests hook this to make sure that the correct state etc exist
|
||||
// after a sync has been completed and a new timer created (or not).
|
||||
_onAutoReschedule() {},
|
||||
};
|
||||
|
||||
let internalScheduler = new InternalScheduler();
|
||||
internalScheduler.init();
|
||||
|
||||
// The public interface into this module is tiny, so a simple object that
|
||||
// delegates to the implementation.
|
||||
let ReadingListScheduler = {
|
||||
get STATE_OK() internalScheduler.STATE_OK,
|
||||
get STATE_ERROR_AUTHENTICATION() internalScheduler.STATE_ERROR_AUTHENTICATION,
|
||||
get STATE_ERROR_OTHER() internalScheduler.STATE_ERROR_OTHER,
|
||||
|
||||
get state() internalScheduler.state,
|
||||
};
|
||||
|
||||
// These functions are exposed purely for tests, which manage to grab them
|
||||
// via a BackstagePass.
|
||||
function createTestableScheduler(readingList) {
|
||||
// kill the "real" scheduler as we don't want it listening to notifications etc.
|
||||
if (internalScheduler) {
|
||||
internalScheduler.finalize();
|
||||
internalScheduler = null;
|
||||
}
|
||||
// No .init() call - that's up to the tests after hooking.
|
||||
return new InternalScheduler(readingList);
|
||||
}
|
||||
|
||||
// mochitests want the internal state of the real scheduler for various things.
|
||||
function getInternalScheduler() {
|
||||
return internalScheduler;
|
||||
}
|
|
@ -1,178 +0,0 @@
|
|||
/* 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/. */
|
||||
|
||||
// The client used to access the ReadingList server.
|
||||
|
||||
"use strict";
|
||||
|
||||
const { classes: Cc, interfaces: Ci, utils: Cu } = Components;
|
||||
|
||||
Cu.import("resource://gre/modules/XPCOMUtils.jsm");
|
||||
Cu.import("resource://gre/modules/Services.jsm");
|
||||
Cu.import("resource://gre/modules/Log.jsm");
|
||||
Cu.import("resource://gre/modules/Task.jsm");
|
||||
|
||||
XPCOMUtils.defineLazyModuleGetter(this, "RESTRequest", "resource://services-common/rest.js");
|
||||
XPCOMUtils.defineLazyModuleGetter(this, "CommonUtils", "resource://services-common/utils.js");
|
||||
XPCOMUtils.defineLazyModuleGetter(this, "fxAccounts", "resource://gre/modules/FxAccounts.jsm");
|
||||
|
||||
let log = Log.repository.getLogger("readinglist.serverclient");
|
||||
|
||||
const OAUTH_SCOPE = "readinglist"; // The "scope" on the oauth token we request.
|
||||
|
||||
this.EXPORTED_SYMBOLS = [
|
||||
"ServerClient",
|
||||
];
|
||||
|
||||
// utf-8 joy. rest.js, which we use for the underlying requests, does *not*
|
||||
// encode the request as utf-8 even though it wants to know the encoding.
|
||||
// It does, however, explicitly decode the response. This seems insane, but is
|
||||
// what it is.
|
||||
// The end result being we need to utf-8 the request and let the response take
|
||||
// care of itself.
|
||||
function objectToUTF8Json(obj) {
|
||||
// FTR, unescape(encodeURIComponent(JSON.stringify(obj))) also works ;)
|
||||
return CommonUtils.encodeUTF8(JSON.stringify(obj));
|
||||
}
|
||||
|
||||
function ServerClient(fxa = fxAccounts) {
|
||||
this.fxa = fxa;
|
||||
}
|
||||
|
||||
ServerClient.prototype = {
|
||||
|
||||
request(options) {
|
||||
return this._request(options.path, options.method, options.body, options.headers);
|
||||
},
|
||||
|
||||
get serverURL() {
|
||||
return Services.prefs.getCharPref("readinglist.server");
|
||||
},
|
||||
|
||||
_getURL(path) {
|
||||
let result = this.serverURL;
|
||||
// we expect the path to have a leading slash, so remove any trailing
|
||||
// slashes on the pref.
|
||||
if (result.endsWith("/")) {
|
||||
result = result.slice(0, -1);
|
||||
}
|
||||
return result + path;
|
||||
},
|
||||
|
||||
// Hook points for testing.
|
||||
_getToken() {
|
||||
// Assume token-caching is in place - if it's not we should avoid doing
|
||||
// this each request.
|
||||
return this.fxa.getOAuthToken({scope: OAUTH_SCOPE});
|
||||
},
|
||||
|
||||
_removeToken(token) {
|
||||
return this.fxa.removeCachedOAuthToken({token});
|
||||
},
|
||||
|
||||
// Converts an error from the RESTRequest object to an error we export.
|
||||
_convertRestError(error) {
|
||||
return error; // XXX - errors?
|
||||
},
|
||||
|
||||
// Converts an error from a try/catch handler to an error we export.
|
||||
_convertJSError(error) {
|
||||
return error; // XXX - errors?
|
||||
},
|
||||
|
||||
/*
|
||||
* Perform a request - handles authentication
|
||||
*/
|
||||
_request: Task.async(function* (path, method, body, headers) {
|
||||
let token = yield this._getToken();
|
||||
let response = yield this._rawRequest(path, method, body, headers, token);
|
||||
log.debug("initial request got status ${status}", response);
|
||||
if (response.status == 401) {
|
||||
// an auth error - assume our token has expired or similar.
|
||||
this._removeToken(token);
|
||||
token = yield this._getToken();
|
||||
response = yield this._rawRequest(path, method, body, headers, token);
|
||||
log.debug("retry of request got status ${status}", response);
|
||||
}
|
||||
return response;
|
||||
}),
|
||||
|
||||
/*
|
||||
* Perform a request *without* abstractions such as auth etc
|
||||
*
|
||||
* On success (which *includes* non-200 responses) returns an object like:
|
||||
* {
|
||||
* status: 200, # http status code
|
||||
* headers: {}, # header values keyed by header name.
|
||||
* body: {}, # parsed json
|
||||
}
|
||||
*/
|
||||
|
||||
_rawRequest(path, method, body, headers, oauthToken) {
|
||||
return new Promise((resolve, reject) => {
|
||||
let url = this._getURL(path);
|
||||
log.debug("dispatching request to", url);
|
||||
let request = new RESTRequest(url);
|
||||
method = method.toUpperCase();
|
||||
|
||||
request.setHeader("Accept", "application/json");
|
||||
request.setHeader("Content-Type", "application/json; charset=utf-8");
|
||||
request.setHeader("Authorization", "Bearer " + oauthToken);
|
||||
// and additional header specified for this request.
|
||||
if (headers) {
|
||||
for (let [headerName, headerValue] in Iterator(headers)) {
|
||||
log.trace("Caller specified header: ${headerName}=${headerValue}", {headerName, headerValue});
|
||||
request.setHeader(headerName, headerValue);
|
||||
}
|
||||
}
|
||||
|
||||
request.onComplete = error => {
|
||||
// Although the server API docs say the "Backoff" header is on
|
||||
// successful responses while "Retry-After" is on error responses, we
|
||||
// just look for them both in both cases (as the scheduler makes no
|
||||
// distinction)
|
||||
let response = request.response;
|
||||
if (response && response.headers) {
|
||||
let backoff = response.headers["backoff"] || response.headers["retry-after"];
|
||||
if (backoff) {
|
||||
let numeric = backoff.toLowerCase() == "none" ? 0 :
|
||||
parseInt(backoff, 10);
|
||||
if (isNaN(numeric)) {
|
||||
log.info("Server requested unrecognized backoff", backoff);
|
||||
} else if (numeric > 0) {
|
||||
log.info("Server requested backoff", numeric);
|
||||
Services.obs.notifyObservers(null, "readinglist:backoff-requested", String(numeric));
|
||||
}
|
||||
}
|
||||
}
|
||||
if (error) {
|
||||
return reject(this._convertRestError(error));
|
||||
}
|
||||
|
||||
log.debug("received response status: ${status} ${statusText}", response);
|
||||
// Handle response status codes we know about
|
||||
let result = {
|
||||
status: response.status,
|
||||
headers: response.headers
|
||||
};
|
||||
try {
|
||||
if (response.body) {
|
||||
result.body = JSON.parse(response.body);
|
||||
}
|
||||
} catch (e) {
|
||||
log.debug("Response is not JSON. First 1024 chars: |${body}|",
|
||||
{ body: response.body.substr(0, 1024) });
|
||||
// We don't reject due to this (and don't even make a huge amount of
|
||||
// log noise - eg, a 50X error from a load balancer etc may not write
|
||||
// JSON.
|
||||
}
|
||||
|
||||
resolve(result);
|
||||
}
|
||||
// We are assuming the body has already been decoded and thus contains
|
||||
// unicode, but the server expects utf-8. encodeURIComponent does that.
|
||||
request.dispatch(method, objectToUTF8Json(body));
|
||||
});
|
||||
},
|
||||
};
|
|
@ -1,664 +0,0 @@
|
|||
/* 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 = [
|
||||
"Sync",
|
||||
];
|
||||
|
||||
const { classes: Cc, interfaces: Ci, utils: Cu } = Components;
|
||||
|
||||
Cu.import("resource://gre/modules/Log.jsm");
|
||||
Cu.import("resource://gre/modules/Task.jsm");
|
||||
Cu.import("resource://gre/modules/XPCOMUtils.jsm");
|
||||
|
||||
XPCOMUtils.defineLazyModuleGetter(this, "Preferences",
|
||||
"resource://gre/modules/Preferences.jsm");
|
||||
XPCOMUtils.defineLazyModuleGetter(this, "ReadingList",
|
||||
"resource:///modules/readinglist/ReadingList.jsm");
|
||||
XPCOMUtils.defineLazyModuleGetter(this, "ServerClient",
|
||||
"resource:///modules/readinglist/ServerClient.jsm");
|
||||
|
||||
// The maximum number of sub-requests per POST /batch supported by the server.
|
||||
// See http://readinglist.readthedocs.org/en/latest/api/batch.html.
|
||||
const BATCH_REQUEST_LIMIT = 25;
|
||||
|
||||
// The Last-Modified header of server responses is stored here.
|
||||
const SERVER_LAST_MODIFIED_HEADER_PREF = "readinglist.sync.serverLastModified";
|
||||
|
||||
// Maps local record properties to server record properties.
|
||||
const SERVER_PROPERTIES_BY_LOCAL_PROPERTIES = {
|
||||
guid: "id",
|
||||
serverLastModified: "last_modified",
|
||||
url: "url",
|
||||
preview: "preview",
|
||||
title: "title",
|
||||
resolvedURL: "resolved_url",
|
||||
resolvedTitle: "resolved_title",
|
||||
excerpt: "excerpt",
|
||||
archived: "archived",
|
||||
deleted: "deleted",
|
||||
favorite: "favorite",
|
||||
isArticle: "is_article",
|
||||
wordCount: "word_count",
|
||||
unread: "unread",
|
||||
addedBy: "added_by",
|
||||
addedOn: "added_on",
|
||||
storedOn: "stored_on",
|
||||
markedReadBy: "marked_read_by",
|
||||
markedReadOn: "marked_read_on",
|
||||
readPosition: "read_position",
|
||||
};
|
||||
|
||||
// Local record properties that can be uploaded in new items.
|
||||
const NEW_RECORD_PROPERTIES = `
|
||||
url
|
||||
title
|
||||
resolvedURL
|
||||
resolvedTitle
|
||||
excerpt
|
||||
favorite
|
||||
isArticle
|
||||
wordCount
|
||||
unread
|
||||
addedBy
|
||||
addedOn
|
||||
markedReadBy
|
||||
markedReadOn
|
||||
readPosition
|
||||
preview
|
||||
`.trim().split(/\s+/);
|
||||
|
||||
// Local record properties that can be uploaded in changed items.
|
||||
const MUTABLE_RECORD_PROPERTIES = `
|
||||
title
|
||||
resolvedURL
|
||||
resolvedTitle
|
||||
excerpt
|
||||
favorite
|
||||
isArticle
|
||||
wordCount
|
||||
unread
|
||||
markedReadBy
|
||||
markedReadOn
|
||||
readPosition
|
||||
preview
|
||||
`.trim().split(/\s+/);
|
||||
|
||||
let log = Log.repository.getLogger("readinglist.sync");
|
||||
|
||||
|
||||
/**
|
||||
* An object that syncs reading list state with a server. To sync, make a new
|
||||
* SyncImpl object and then call start() on it.
|
||||
*
|
||||
* @param readingList The ReadingList to sync.
|
||||
*/
|
||||
function SyncImpl(readingList) {
|
||||
this.list = readingList;
|
||||
this._client = new ServerClient();
|
||||
}
|
||||
|
||||
/**
|
||||
* This implementation uses the sync algorithm described here:
|
||||
* https://github.com/mozilla-services/readinglist/wiki/Client-phases
|
||||
* The "phases" mentioned in the methods below refer to the phases in that
|
||||
* document.
|
||||
*/
|
||||
SyncImpl.prototype = {
|
||||
|
||||
/**
|
||||
* Starts sync, if it's not already started.
|
||||
*
|
||||
* @return Promise<null> this.promise, i.e., a promise that will be resolved
|
||||
* when sync completes, rejected on error.
|
||||
*/
|
||||
start() {
|
||||
if (!this.promise) {
|
||||
this.promise = Task.spawn(function* () {
|
||||
try {
|
||||
yield this._start();
|
||||
} finally {
|
||||
delete this.promise;
|
||||
}
|
||||
}.bind(this));
|
||||
}
|
||||
return this.promise;
|
||||
},
|
||||
|
||||
/**
|
||||
* A Promise<null> that will be non-null when sync is ongoing. Resolved when
|
||||
* sync completes, rejected on error.
|
||||
*/
|
||||
promise: null,
|
||||
|
||||
/**
|
||||
* See the document linked above that describes the sync algorithm.
|
||||
*/
|
||||
_start: Task.async(function* () {
|
||||
log.info("Starting sync");
|
||||
yield this._logDiagnostics();
|
||||
yield this._uploadStatusChanges();
|
||||
yield this._uploadNewItems();
|
||||
yield this._uploadDeletedItems();
|
||||
yield this._downloadModifiedItems();
|
||||
|
||||
// TODO: "Repeat [this phase] until no conflicts occur," says the doc.
|
||||
yield this._uploadMaterialChanges();
|
||||
|
||||
log.info("Sync done");
|
||||
}),
|
||||
|
||||
/**
|
||||
* Phase 0 - for debugging we log some stuff about the local store before
|
||||
* we start syncing.
|
||||
* We only do this when the log level is "Trace" or lower as the info (a)
|
||||
* may be expensive to generate, (b) generate alot of output and (c) may
|
||||
* contain private information.
|
||||
*/
|
||||
_logDiagnostics: Task.async(function* () {
|
||||
// Sadly our log is likely to have Log.Level.All, so loop over our
|
||||
// appenders looking for the effective level.
|
||||
let smallestLevel = log.appenders.reduce(
|
||||
(prev, appender) => Math.min(prev, appender.level),
|
||||
Log.Level.Error);
|
||||
|
||||
if (smallestLevel > Log.Level.Trace) {
|
||||
return;
|
||||
}
|
||||
|
||||
let localItems = [];
|
||||
yield this.list.forEachItem(localItem => localItems.push(localItem));
|
||||
log.trace("Have " + localItems.length + " local item(s)");
|
||||
for (let localItem of localItems) {
|
||||
// We need to use .record so we get access to a couple of the "internal" fields.
|
||||
let record = localItem._record;
|
||||
let redacted = {};
|
||||
for (let attr of ["guid", "url", "resolvedURL", "serverLastModified", "syncStatus"]) {
|
||||
redacted[attr] = record[attr];
|
||||
}
|
||||
log.trace(JSON.stringify(redacted));
|
||||
}
|
||||
// and the GUIDs of deleted items.
|
||||
let deletedGuids = []
|
||||
yield this.list.forEachSyncedDeletedGUID(guid => deletedGuids.push(guid));
|
||||
// This might be a huge line, but that's OK.
|
||||
log.trace("Have ${num} deleted item(s): ${deletedGuids}", {num: deletedGuids.length, deletedGuids});
|
||||
}),
|
||||
|
||||
/**
|
||||
* Phase 1 part 1
|
||||
*
|
||||
* Uploads not-new items with status-only changes. By design, status-only
|
||||
* changes will never conflict with what's on the server.
|
||||
*/
|
||||
_uploadStatusChanges: Task.async(function* () {
|
||||
log.debug("Phase 1 part 1: Uploading status changes");
|
||||
yield this._uploadChanges(ReadingList.SyncStatus.CHANGED_STATUS,
|
||||
ReadingList.SyncStatusProperties.STATUS);
|
||||
}),
|
||||
|
||||
/**
|
||||
* There are two phases for uploading changed not-new items: one for items
|
||||
* with status-only changes, one for items with material changes. The two
|
||||
* work similarly mechanically, and this method is a helper for both.
|
||||
*
|
||||
* @param syncStatus Local items matching this sync status will be uploaded.
|
||||
* @param localProperties An array of local record property names. The
|
||||
* uploaded item records will include only these properties.
|
||||
*/
|
||||
_uploadChanges: Task.async(function* (syncStatus, localProperties) {
|
||||
// Get local items that match the given syncStatus.
|
||||
let requests = [];
|
||||
yield this.list.forEachItem(localItem => {
|
||||
requests.push({
|
||||
path: "/articles/" + localItem.guid,
|
||||
body: serverRecordFromLocalItem(localItem, localProperties),
|
||||
});
|
||||
}, { syncStatus: syncStatus });
|
||||
if (!requests.length) {
|
||||
log.debug("No local changes to upload");
|
||||
return;
|
||||
}
|
||||
|
||||
// Send the request.
|
||||
let request = {
|
||||
body: {
|
||||
defaults: {
|
||||
method: "PATCH",
|
||||
},
|
||||
requests: requests,
|
||||
},
|
||||
};
|
||||
let batchResponse = yield this._postBatch(request);
|
||||
if (batchResponse.status != 200) {
|
||||
this._handleUnexpectedResponse(true, "uploading changes", batchResponse);
|
||||
return;
|
||||
}
|
||||
|
||||
// Update local items based on the response.
|
||||
for (let response of batchResponse.body.responses) {
|
||||
if (response.status == 404) {
|
||||
// item deleted
|
||||
yield this._deleteItemForGUID(response.body.id);
|
||||
continue;
|
||||
}
|
||||
if (response.status == 409) {
|
||||
// "Conflict": A change violated a uniqueness constraint. Mark the item
|
||||
// as having material changes, and reconcile and upload it in the
|
||||
// material-changes phase.
|
||||
// TODO
|
||||
continue;
|
||||
}
|
||||
if (response.status != 200) {
|
||||
this._handleUnexpectedResponse(false, "uploading a change", response);
|
||||
continue;
|
||||
}
|
||||
// Don't assume the local record and the server record aren't materially
|
||||
// different. Reconcile the differences.
|
||||
// TODO
|
||||
|
||||
let item = yield this._itemForGUID(response.body.id);
|
||||
yield this._updateItemWithServerRecord(item, response.body);
|
||||
}
|
||||
}),
|
||||
|
||||
/**
|
||||
* Phase 1 part 2
|
||||
*
|
||||
* Uploads new items.
|
||||
*/
|
||||
_uploadNewItems: Task.async(function* () {
|
||||
log.debug("Phase 1 part 2: Uploading new items");
|
||||
|
||||
// Get new local items.
|
||||
let requests = [];
|
||||
yield this.list.forEachItem(localItem => {
|
||||
requests.push({
|
||||
body: serverRecordFromLocalItem(localItem, NEW_RECORD_PROPERTIES),
|
||||
});
|
||||
}, { syncStatus: ReadingList.SyncStatus.NEW });
|
||||
if (!requests.length) {
|
||||
log.debug("No new local items to upload");
|
||||
return;
|
||||
}
|
||||
|
||||
// Send the request.
|
||||
let request = {
|
||||
body: {
|
||||
defaults: {
|
||||
method: "POST",
|
||||
path: "/articles",
|
||||
},
|
||||
requests: requests,
|
||||
},
|
||||
};
|
||||
let batchResponse = yield this._postBatch(request);
|
||||
if (batchResponse.status != 200) {
|
||||
this._handleUnexpectedResponse(true, "uploading new items", batchResponse);
|
||||
return;
|
||||
}
|
||||
|
||||
// Update local items based on the response.
|
||||
for (let response of batchResponse.body.responses) {
|
||||
if (response.status == 303) {
|
||||
// "See Other": An item with the URL already exists. Mark the item as
|
||||
// having material changes, and reconcile and upload it in the
|
||||
// material-changes phase.
|
||||
// TODO
|
||||
continue;
|
||||
}
|
||||
// Note that the server seems to return a 200 if an identical item already
|
||||
// exists, but we shouldn't be uploading identical items in this phase in
|
||||
// normal usage. But if something goes wrong locally (eg, we upload but
|
||||
// get some error even though the upload worked) we will see this.
|
||||
// So allow 200 but log a warning.
|
||||
if (response.status == 200) {
|
||||
log.debug("Attempting to upload a new item found the server already had it", response);
|
||||
// but we still process it.
|
||||
} else if (response.status != 201) {
|
||||
this._handleUnexpectedResponse(false, "uploading a new item", response);
|
||||
continue;
|
||||
}
|
||||
let item = yield this.list.itemForURL(response.body.url);
|
||||
yield this._updateItemWithServerRecord(item, response.body);
|
||||
}
|
||||
}),
|
||||
|
||||
/**
|
||||
* Phase 1 part 3
|
||||
*
|
||||
* Uploads deleted synced items.
|
||||
*/
|
||||
_uploadDeletedItems: Task.async(function* () {
|
||||
log.debug("Phase 1 part 3: Uploading deleted items");
|
||||
|
||||
// Get deleted synced local items.
|
||||
let requests = [];
|
||||
yield this.list.forEachSyncedDeletedGUID(guid => {
|
||||
requests.push({
|
||||
path: "/articles/" + guid,
|
||||
});
|
||||
});
|
||||
if (!requests.length) {
|
||||
log.debug("No local deleted synced items to upload");
|
||||
return;
|
||||
}
|
||||
|
||||
// Send the request.
|
||||
let request = {
|
||||
body: {
|
||||
defaults: {
|
||||
method: "DELETE",
|
||||
},
|
||||
requests: requests,
|
||||
},
|
||||
};
|
||||
let batchResponse = yield this._postBatch(request);
|
||||
if (batchResponse.status != 200) {
|
||||
this._handleUnexpectedResponse(true, "uploading deleted items", batchResponse);
|
||||
return;
|
||||
}
|
||||
|
||||
// Delete local items based on the response.
|
||||
for (let response of batchResponse.body.responses) {
|
||||
// A 404 means the item was already deleted on the server, which is OK.
|
||||
// We still need to make sure it's deleted locally, though.
|
||||
if (response.status != 200 && response.status != 404) {
|
||||
this._handleUnexpectedResponse(false, "uploading a deleted item", response);
|
||||
continue;
|
||||
}
|
||||
yield this._deleteItemForGUID(response.body.id);
|
||||
}
|
||||
}),
|
||||
|
||||
/**
|
||||
* Phase 2
|
||||
*
|
||||
* Downloads items that were modified since the last sync.
|
||||
*/
|
||||
_downloadModifiedItems: Task.async(function* () {
|
||||
log.debug("Phase 2: Downloading modified items");
|
||||
|
||||
// Get modified items from the server.
|
||||
let path = "/articles";
|
||||
if (this._serverLastModifiedHeader) {
|
||||
path += "?_since=" + this._serverLastModifiedHeader;
|
||||
}
|
||||
let request = {
|
||||
method: "GET",
|
||||
path: path,
|
||||
};
|
||||
let response = yield this._sendRequest(request);
|
||||
if (response.status != 200) {
|
||||
this._handleUnexpectedResponse(true, "downloading modified items", response);
|
||||
return;
|
||||
}
|
||||
|
||||
// Update local items based on the response.
|
||||
for (let serverRecord of response.body.items) {
|
||||
if (serverRecord.deleted) {
|
||||
// _deleteItemForGUID is a no-op if no item exists with the GUID.
|
||||
yield this._deleteItemForGUID(serverRecord.id);
|
||||
continue;
|
||||
}
|
||||
let localItem = yield this._itemForGUID(serverRecord.id);
|
||||
if (localItem) {
|
||||
if (localItem.serverLastModified == serverRecord.last_modified) {
|
||||
// We just uploaded this item in the new-items phase.
|
||||
continue;
|
||||
}
|
||||
// The local item may have materially changed. In that case, don't
|
||||
// overwrite the local changes with the server record. Instead, mark
|
||||
// the item as having material changes and reconcile and upload it in
|
||||
// the material-changes phase.
|
||||
// TODO
|
||||
|
||||
yield this._updateItemWithServerRecord(localItem, serverRecord);
|
||||
continue;
|
||||
}
|
||||
// A potentially new item. addItem() will fail here when an item was
|
||||
// added to the local list between the time we uploaded new items and
|
||||
// now.
|
||||
let localRecord = localRecordFromServerRecord(serverRecord);
|
||||
try {
|
||||
yield this.list.addItem(localRecord);
|
||||
} catch (ex) {
|
||||
if (ex instanceof ReadingList.Error.Exists) {
|
||||
log.debug("Tried to add an item that already exists.");
|
||||
} else {
|
||||
log.error("Error adding an item from server record ${serverRecord} ${ex}",
|
||||
{ serverRecord, ex });
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Now that changes have been successfully applied, advance the server
|
||||
// last-modified timestamp so that next time we fetch items starting from
|
||||
// the current point. Response header names are lowercase.
|
||||
if (response.headers && "last-modified" in response.headers) {
|
||||
this._serverLastModifiedHeader = response.headers["last-modified"];
|
||||
}
|
||||
}),
|
||||
|
||||
/**
|
||||
* Phase 3 (material changes)
|
||||
*
|
||||
* Uploads not-new items with material changes.
|
||||
*/
|
||||
_uploadMaterialChanges: Task.async(function* () {
|
||||
log.debug("Phase 3: Uploading material changes");
|
||||
yield this._uploadChanges(ReadingList.SyncStatus.CHANGED_MATERIAL,
|
||||
MUTABLE_RECORD_PROPERTIES);
|
||||
}),
|
||||
|
||||
/**
|
||||
* Gets the local ReadingListItem with the given GUID.
|
||||
*
|
||||
* @param guid The item's GUID.
|
||||
* @return The matching ReadingListItem.
|
||||
*/
|
||||
_itemForGUID: Task.async(function* (guid) {
|
||||
return (yield this.list.item({ guid: guid }));
|
||||
}),
|
||||
|
||||
/**
|
||||
* Updates the given local ReadingListItem with the given server record. The
|
||||
* local item's sync status is updated to reflect the fact that the item has
|
||||
* been synced and is up to date.
|
||||
*
|
||||
* @param item A local ReadingListItem.
|
||||
* @param serverRecord A server record representing the item.
|
||||
*/
|
||||
_updateItemWithServerRecord: Task.async(function* (localItem, serverRecord) {
|
||||
if (!localItem) {
|
||||
// The item may have been deleted from the local list between the time we
|
||||
// saw that it needed updating and now.
|
||||
log.debug("Tried to update a null local item from server record",
|
||||
serverRecord);
|
||||
return;
|
||||
}
|
||||
localItem._record = localRecordFromServerRecord(serverRecord);
|
||||
try {
|
||||
yield this.list.updateItem(localItem);
|
||||
} catch (ex) {
|
||||
// The item may have been deleted from the local list after we fetched it.
|
||||
if (ex instanceof ReadingList.Error.Deleted) {
|
||||
log.debug("Tried to update an item that was deleted from server record",
|
||||
serverRecord);
|
||||
} else {
|
||||
log.error("Error updating an item from server record ${serverRecord} ${ex}",
|
||||
{ serverRecord, ex });
|
||||
}
|
||||
}
|
||||
}),
|
||||
|
||||
/**
|
||||
* Truly deletes the local ReadingListItem with the given GUID.
|
||||
*
|
||||
* @param guid The item's GUID.
|
||||
*/
|
||||
_deleteItemForGUID: Task.async(function* (guid) {
|
||||
let item = yield this._itemForGUID(guid);
|
||||
if (item) {
|
||||
// If item is non-null, then it hasn't been deleted locally. Therefore
|
||||
// it's important to delete it through its list so that the list and its
|
||||
// consumers are notified properly. Set the syncStatus to NEW so that the
|
||||
// list truly deletes the item.
|
||||
item._record.syncStatus = ReadingList.SyncStatus.NEW;
|
||||
try {
|
||||
yield this.list.deleteItem(item);
|
||||
} catch (ex) {
|
||||
log.error("Failed delete local item with id ${guid} ${ex}",
|
||||
{ guid, ex });
|
||||
}
|
||||
return;
|
||||
}
|
||||
// If item is null, then it may not actually exist locally, or it may have
|
||||
// been synced and then deleted so that it's marked as being deleted. In
|
||||
// that case, try to delete it directly from the store. As far as the list
|
||||
// is concerned, the item has already been deleted.
|
||||
log.debug("Item not present in list, deleting it by GUID instead");
|
||||
try {
|
||||
this.list._store.deleteItemByGUID(guid);
|
||||
} catch (ex) {
|
||||
log.error("Failed to delete local item with id ${guid} ${ex}",
|
||||
{ guid, ex });
|
||||
}
|
||||
}),
|
||||
|
||||
/**
|
||||
* Sends a request to the server.
|
||||
*
|
||||
* @param req The request object: { method, path, body, headers }.
|
||||
* @return Promise<response> Resolved with the server's response object:
|
||||
* { status, body, headers }.
|
||||
*/
|
||||
_sendRequest: Task.async(function* (req) {
|
||||
log.debug("Sending request", req);
|
||||
let response = yield this._client.request(req);
|
||||
log.debug("Received response", response);
|
||||
return response;
|
||||
}),
|
||||
|
||||
/**
|
||||
* The server limits the number of sub-requests in POST /batch'es to
|
||||
* BATCH_REQUEST_LIMIT. This method takes an arbitrarily big batch request
|
||||
* and breaks it apart into many individual batch requests in order to stay
|
||||
* within the limit.
|
||||
*
|
||||
* @param bigRequest The same type of request object that _sendRequest takes.
|
||||
* Since it's a POST /batch request, its `body` should have a
|
||||
* `requests` property whose value is an array of sub-requests.
|
||||
* `method` and `path` are automatically filled.
|
||||
* @return Promise<response> Resolved when all requests complete with 200s, or
|
||||
* when the first response that is not a 200 is received. In the
|
||||
* first case, the resolved response is a combination of all the
|
||||
* server responses, and response.body.responses contains the sub-
|
||||
* responses for all the sub-requests in bigRequest. In the second
|
||||
* case, the resolved response is the non-200 response straight from
|
||||
* the server.
|
||||
*/
|
||||
_postBatch: Task.async(function* (bigRequest) {
|
||||
log.debug("Sending batch requests");
|
||||
let allSubResponses = [];
|
||||
let remainingSubRequests = bigRequest.body.requests;
|
||||
while (remainingSubRequests.length) {
|
||||
let request = Object.assign({}, bigRequest);
|
||||
request.method = "POST";
|
||||
request.path = "/batch";
|
||||
request.body.requests =
|
||||
remainingSubRequests.splice(0, BATCH_REQUEST_LIMIT);
|
||||
let response = yield this._sendRequest(request);
|
||||
if (response.status != 200) {
|
||||
return response;
|
||||
}
|
||||
allSubResponses = allSubResponses.concat(response.body.responses);
|
||||
}
|
||||
let bigResponse = {
|
||||
status: 200,
|
||||
body: {
|
||||
responses: allSubResponses,
|
||||
},
|
||||
};
|
||||
log.debug("All batch requests successfully sent");
|
||||
return bigResponse;
|
||||
}),
|
||||
|
||||
_handleUnexpectedResponse(isTopLevel, contextMsgFragment, response) {
|
||||
log.error(`Unexpected response ${contextMsgFragment}`, response);
|
||||
// We want to throw in some cases so the sync engine knows there was an
|
||||
// error and retries using the error schedule. 401 implies an auth issue
|
||||
// (possibly transient, possibly not) - but things like 404 might just
|
||||
// relate to a single item and need not throw. Any 5XX implies a
|
||||
// (hopefully transient) server error.
|
||||
if (isTopLevel && (response.status == 401 || response.status >= 500)) {
|
||||
throw new Error("Sync aborted due to " + response.status + " server response.");
|
||||
}
|
||||
},
|
||||
|
||||
// TODO: Wipe this pref when user logs out.
|
||||
get _serverLastModifiedHeader() {
|
||||
if (!("__serverLastModifiedHeader" in this)) {
|
||||
this.__serverLastModifiedHeader =
|
||||
Preferences.get(SERVER_LAST_MODIFIED_HEADER_PREF, undefined);
|
||||
}
|
||||
return this.__serverLastModifiedHeader;
|
||||
},
|
||||
set _serverLastModifiedHeader(val) {
|
||||
this.__serverLastModifiedHeader = val;
|
||||
Preferences.set(SERVER_LAST_MODIFIED_HEADER_PREF, val);
|
||||
},
|
||||
};
|
||||
|
||||
|
||||
/**
|
||||
* Translates a local ReadingListItem into a server record.
|
||||
*
|
||||
* @param localItem The local ReadingListItem.
|
||||
* @param localProperties An array of local item property names. Only these
|
||||
* properties will be included in the server record.
|
||||
* @return The server record.
|
||||
*/
|
||||
function serverRecordFromLocalItem(localItem, localProperties) {
|
||||
let serverRecord = {};
|
||||
for (let localProp of localProperties) {
|
||||
let serverProp = SERVER_PROPERTIES_BY_LOCAL_PROPERTIES[localProp];
|
||||
if (localProp in localItem._record) {
|
||||
serverRecord[serverProp] = localItem._record[localProp];
|
||||
}
|
||||
}
|
||||
return serverRecord;
|
||||
}
|
||||
|
||||
/**
|
||||
* Translates a server record into a local record. The returned local record's
|
||||
* syncStatus will reflect the fact that the local record is up-to-date synced.
|
||||
*
|
||||
* @param serverRecord The server record.
|
||||
* @return The local record.
|
||||
*/
|
||||
function localRecordFromServerRecord(serverRecord) {
|
||||
let localRecord = {
|
||||
// Mark the record as being up-to-date synced.
|
||||
syncStatus: ReadingList.SyncStatus.SYNCED,
|
||||
};
|
||||
for (let localProp in SERVER_PROPERTIES_BY_LOCAL_PROPERTIES) {
|
||||
let serverProp = SERVER_PROPERTIES_BY_LOCAL_PROPERTIES[localProp];
|
||||
if (serverProp in serverRecord) {
|
||||
localRecord[localProp] = serverRecord[serverProp];
|
||||
}
|
||||
}
|
||||
return localRecord;
|
||||
}
|
||||
|
||||
Object.defineProperty(this, "Sync", {
|
||||
get() {
|
||||
if (!this._singleton) {
|
||||
this._singleton = new SyncImpl(ReadingList);
|
||||
}
|
||||
return this._singleton;
|
||||
},
|
||||
});
|
|
@ -1,24 +0,0 @@
|
|||
# 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',
|
||||
'Scheduler.jsm',
|
||||
'ServerClient.jsm',
|
||||
'SQLiteStore.jsm',
|
||||
'Sync.jsm',
|
||||
]
|
||||
|
||||
TESTING_JS_MODULES += [
|
||||
'test/ReadingListTestUtils.jsm',
|
||||
]
|
||||
|
||||
BROWSER_CHROME_MANIFESTS += ['test/browser/browser.ini']
|
||||
|
||||
XPCSHELL_TESTS_MANIFESTS += ['test/xpcshell/xpcshell.ini']
|
||||
|
||||
with Files('**'):
|
||||
BUG_COMPONENT = ('Firefox', 'Reading List')
|
|
@ -1,484 +0,0 @@
|
|||
/* 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/Task.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,
|
||||
|
||||
/**
|
||||
* 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}
|
||||
*/
|
||||
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.emptyListInfo = document.getElementById("emptyListInfo");
|
||||
this.itemTemplate = document.getElementById("item-template");
|
||||
|
||||
// click events for middle-clicks are not sent to DOM nodes, only to the document.
|
||||
document.addEventListener("click", event => this.onClick(event));
|
||||
|
||||
this.list.addEventListener("mousemove", event => this.onListMouseMove(event));
|
||||
this.list.addEventListener("keydown", event => this.onListKeyDown(event), true);
|
||||
|
||||
window.addEventListener("message", event => this.onMessage(event));
|
||||
|
||||
this.listPromise = this.ensureListItems();
|
||||
ReadingList.addListener(this);
|
||||
|
||||
Services.prefs.setBoolPref("browser.readinglist.sidebarEverOpened", true);
|
||||
|
||||
let initEvent = new CustomEvent("Initialized", {bubbles: true});
|
||||
document.documentElement.dispatchEvent(initEvent);
|
||||
},
|
||||
|
||||
/**
|
||||
* Un-initialize the sidebar UI.
|
||||
*/
|
||||
uninit() {
|
||||
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 {ReadinglistItem} item - Item that was added.
|
||||
*/
|
||||
onItemAdded(item, append = false) {
|
||||
log.trace(`onItemAdded: ${item}`);
|
||||
|
||||
let itemNode = document.importNode(this.itemTemplate.content, true).firstElementChild;
|
||||
this.updateItem(item, itemNode);
|
||||
// XXX Inserting at the top by default is a temp hack that will stop
|
||||
// working once we start including items received from sync.
|
||||
if (append)
|
||||
this.list.appendChild(itemNode);
|
||||
else
|
||||
this.list.insertBefore(itemNode, this.list.firstChild);
|
||||
this.itemNodesById.set(item.id, itemNode);
|
||||
this.itemsById.set(item.id, item);
|
||||
|
||||
this.emptyListInfo.hidden = true;
|
||||
window.requestAnimationFrame(() => {
|
||||
window.requestAnimationFrame(() => {
|
||||
itemNode.classList.add('visible');
|
||||
});
|
||||
});
|
||||
},
|
||||
|
||||
/**
|
||||
* Handle an item being deleted from the ReadingList.
|
||||
* @param {ReadingListItem} item - Item that was deleted.
|
||||
*/
|
||||
onItemDeleted(item) {
|
||||
log.trace(`onItemDeleted: ${item}`);
|
||||
|
||||
let itemNode = this.itemNodesById.get(item.id);
|
||||
|
||||
this.itemNodesById.delete(item.id);
|
||||
this.itemsById.delete(item.id);
|
||||
|
||||
itemNode.addEventListener('transitionend', (event) => {
|
||||
if (event.propertyName == "max-height") {
|
||||
itemNode.remove();
|
||||
|
||||
// TODO: ensureListItems doesn't yet cope with needing to add one item.
|
||||
//this.ensureListItems();
|
||||
|
||||
this.emptyListInfo.hidden = (this.numItems > 0);
|
||||
}
|
||||
}, false);
|
||||
|
||||
itemNode.classList.remove('visible');
|
||||
},
|
||||
|
||||
/**
|
||||
* Handle an item in the ReadingList having any of its properties changed.
|
||||
* @param {ReadingListItem} 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 {ReadingListItem} item - Item to use as a source.
|
||||
* @param {Element} itemNode - Element to update.
|
||||
*/
|
||||
updateItem(item, itemNode) {
|
||||
itemNode.setAttribute("id", "item-" + item.id);
|
||||
itemNode.setAttribute("title", `${item.title}\n${item.url}`);
|
||||
|
||||
itemNode.querySelector(".item-title").textContent = item.title;
|
||||
|
||||
let domain = item.uri.spec;
|
||||
try {
|
||||
domain = item.uri.host;
|
||||
}
|
||||
catch (err) {}
|
||||
itemNode.querySelector(".item-domain").textContent = domain;
|
||||
|
||||
let thumb = itemNode.querySelector(".item-thumb-container");
|
||||
if (item.preview) {
|
||||
thumb.style.backgroundImage = "url(" + item.preview + ")";
|
||||
} else {
|
||||
thumb.style.removeProperty("background-image");
|
||||
}
|
||||
thumb.classList.toggle("preview-available", !!item.preview);
|
||||
},
|
||||
|
||||
/**
|
||||
* Ensure that the list is populated with the correct items.
|
||||
*/
|
||||
ensureListItems: Task.async(function* () {
|
||||
yield ReadingList.forEachItem(item => {
|
||||
// TODO: Should be batch inserting via DocumentFragment
|
||||
try {
|
||||
this.onItemAdded(item, true);
|
||||
} catch (e) {
|
||||
log.warn("Error adding item", e);
|
||||
}
|
||||
}, {sort: "addedOn", descending: true});
|
||||
this.emptyListInfo.hidden = (this.numItems > 0);
|
||||
}),
|
||||
|
||||
/**
|
||||
* Get the number of items currently displayed in the list.
|
||||
* @type {number}
|
||||
*/
|
||||
get numItems() {
|
||||
return this.list.childElementCount;
|
||||
},
|
||||
|
||||
/**
|
||||
* The list item displayed in the current tab.
|
||||
* @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.trace(`Setting activeItem: ${node ? node.id : null}`);
|
||||
|
||||
if (node && 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 list item selected with the keyboard.
|
||||
* @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.trace(`Setting selectedItem: ${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.trace(`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) {
|
||||
log.debug(`Opening page ${url}`);
|
||||
|
||||
let mainWindow = window.QueryInterface(Ci.nsIInterfaceRequestor)
|
||||
.getInterface(Ci.nsIWebNavigation)
|
||||
.QueryInterface(Ci.nsIDocShellTreeItem)
|
||||
.rootTreeItem
|
||||
.QueryInterface(Ci.nsIInterfaceRequestor)
|
||||
.getInterface(Ci.nsIDOMWindow);
|
||||
|
||||
let currentUrl = mainWindow.gBrowser.currentURI.spec;
|
||||
if (currentUrl.startsWith("about:reader"))
|
||||
url = "about:reader?url=" + encodeURIComponent(url);
|
||||
|
||||
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, 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 sidebar.
|
||||
* @param {Event} event - Triggering event.
|
||||
*/
|
||||
onClick(event) {
|
||||
let itemNode = this.findParentItemNode(event.target);
|
||||
if (!itemNode)
|
||||
return;
|
||||
|
||||
if (event.target.classList.contains("remove-button")) {
|
||||
ReadingList.deleteItem(this.getItemFromNode(itemNode));
|
||||
return;
|
||||
}
|
||||
|
||||
this.activeItem = itemNode;
|
||||
this.openActiveItem(event);
|
||||
},
|
||||
|
||||
/**
|
||||
* Handle a mousemove event over the list box:
|
||||
* If the hovered item isn't the selected one, clear the selection.
|
||||
* @param {Event} event - Triggering event.
|
||||
*/
|
||||
onListMouseMove(event) {
|
||||
let itemNode = this.findParentItemNode(event.target);
|
||||
if (itemNode != this.selectedItem)
|
||||
this.selectedItem = null;
|
||||
},
|
||||
|
||||
/**
|
||||
* 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();
|
||||
|
||||
if (!this.numItems) {
|
||||
return;
|
||||
}
|
||||
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();
|
||||
|
||||
if (!this.numItems) {
|
||||
return;
|
||||
}
|
||||
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 = selectedItem;
|
||||
this.openActiveItem(event);
|
||||
}
|
||||
}
|
||||
},
|
||||
|
||||
/**
|
||||
* Handle a message, typically sent from browser-readinglist.js
|
||||
* @param {Event} event - Triggering event.
|
||||
*/
|
||||
onMessage(event) {
|
||||
let msg = event.data;
|
||||
|
||||
if (msg.topic != "UpdateActiveItem") {
|
||||
return;
|
||||
}
|
||||
|
||||
if (!msg.url) {
|
||||
this.activeItem = null;
|
||||
} else {
|
||||
ReadingList.itemForURL(msg.url).then(item => {
|
||||
let node;
|
||||
if (item && (node = this.itemNodesById.get(item.id))) {
|
||||
this.activeItem = node;
|
||||
}
|
||||
});
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
|
||||
addEventListener("DOMContentLoaded", () => RLSidebar.init());
|
|
@ -1,34 +0,0 @@
|
|||
<?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-lines">
|
||||
<p class="item-title"/>
|
||||
<button class="remove-button" title="&readingList.sidebar.delete.tooltip;"/>
|
||||
</div>
|
||||
<div class="item-domain"></div>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<div id="emptyListInfo" hidden="true">&readingList.sidebar.emptyText;</div>
|
||||
<div id="list" role="listbox" tabindex="1"></div>
|
||||
</body>
|
||||
</html>
|
|
@ -1,169 +0,0 @@
|
|||
"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/Task.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;
|
||||
},
|
||||
|
||||
/**
|
||||
* 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.
|
||||
*/
|
||||
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");
|
||||
|
||||
let domain = item.uri.spec;
|
||||
try {
|
||||
domain = item.uri.host;
|
||||
}
|
||||
catch (err) {}
|
||||
this.Assert.equal(node.querySelector(".item-domain").textContent, domain,
|
||||
"Node's domain element's text should match item title");
|
||||
},
|
||||
|
||||
expectSelectedId(itemId) {
|
||||
let selectedItem = this.RLSidebar.selectedItem;
|
||||
if (itemId == null) {
|
||||
this.Assert.equal(selectedItem, null, "Should have no selected item");
|
||||
} else {
|
||||
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 ReadingList.addItem(data);
|
||||
},
|
||||
|
||||
/**
|
||||
* Cleanup all data, resetting to a blank state.
|
||||
*/
|
||||
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);
|
||||
}
|
||||
}),
|
||||
};
|
|
@ -1,7 +0,0 @@
|
|||
[DEFAULT]
|
||||
support-files =
|
||||
head.js
|
||||
|
||||
[browser_ui_enable_disable.js]
|
||||
[browser_sidebar_list.js]
|
||||
;[browser_sidebar_mouse_nav.js]
|
|
@ -1,49 +0,0 @@
|
|||
/**
|
||||
* 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 RLSidebarUtils.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({
|
||||
url: "http://example.com/article1",
|
||||
title: "Article 1",
|
||||
});
|
||||
RLSidebarUtils.expectNumItems(1);
|
||||
|
||||
info("Adding more items");
|
||||
yield RLUtils.addItem([{
|
||||
url: "http://example.com/article2",
|
||||
title: "Article 2",
|
||||
}, {
|
||||
url: "http://example.com/article3",
|
||||
title: "Article 3",
|
||||
}]);
|
||||
RLSidebarUtils.expectNumItems(3);
|
||||
|
||||
info("Closing sidebar");
|
||||
ReadingListUI.hideSidebar();
|
||||
|
||||
info("Adding another item");
|
||||
yield RLUtils.addItem({
|
||||
url: "http://example.com/article4",
|
||||
title: "Article 4",
|
||||
});
|
||||
|
||||
info("Re-opening sidebar");
|
||||
yield RLSidebarUtils.showSidebar();
|
||||
RLSidebarUtils.expectNumItems(4);
|
||||
});
|
|
@ -1,82 +0,0 @@
|
|||
/**
|
||||
* Test mouse navigation for selecting items in the sidebar.
|
||||
*/
|
||||
|
||||
|
||||
function mouseInteraction(mouseEvent, responseEvent, itemNode) {
|
||||
let eventPromise = BrowserTestUtils.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 = [{
|
||||
url: "http://example.com/article1",
|
||||
title: "Article 1",
|
||||
}, {
|
||||
url: "http://example.com/article2",
|
||||
title: "Article 2",
|
||||
}, {
|
||||
url: "http://example.com/article3",
|
||||
title: "Article 3",
|
||||
}, {
|
||||
url: "http://example.com/article4",
|
||||
title: "Article 4",
|
||||
}, {
|
||||
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 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(items[0].id);
|
||||
RLSidebarUtils.expectActiveId(null);
|
||||
|
||||
info("Mouse move over item 2");
|
||||
yield mouseInteraction("mousemove", "SelectedItemChanged", RLSidebarUtils.list.children[1]);
|
||||
RLSidebarUtils.expectSelectedId(items[1].id);
|
||||
RLSidebarUtils.expectActiveId(null);
|
||||
|
||||
info("Mouse move over item 5");
|
||||
yield mouseInteraction("mousemove", "SelectedItemChanged", RLSidebarUtils.list.children[4]);
|
||||
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(items[0].id);
|
||||
RLSidebarUtils.expectActiveId(null);
|
||||
|
||||
info("Mouse click on item 1");
|
||||
yield mouseInteraction("click", "ActiveItemChanged", RLSidebarUtils.list.children[0]);
|
||||
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(items[2].id);
|
||||
RLSidebarUtils.expectActiveId(items[2].id);
|
||||
});
|
|
@ -1,68 +0,0 @@
|
|||
/**
|
||||
* 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");
|
||||
|
||||
let bookmarksMenubarItem = document.getElementById("menu_readingList");
|
||||
let bookmarksMenubarSeparator = document.getElementById("menu_readingListSeparator");
|
||||
|
||||
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");
|
||||
|
||||
// Currently disabled on OSX.
|
||||
if (bookmarksMenubarItem) {
|
||||
Assert.notEqual(bookmarksMenubarItem.getAttribute("hidden"), "true",
|
||||
"RL bookmarks submenu in menubar should not be hidden");
|
||||
Assert.notEqual(sidebarMenuitem.getAttribute("hidden"), "true",
|
||||
"RL bookmarks separator in menubar 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");
|
||||
|
||||
// Currently disabled on OSX.
|
||||
if (bookmarksMenubarItem) {
|
||||
Assert.equal(bookmarksMenubarItem.getAttribute("hidden"), "true",
|
||||
"RL bookmarks submenu in menubar should not be hidden");
|
||||
Assert.equal(sidebarMenuitem.getAttribute("hidden"), "true",
|
||||
"RL bookmarks separator in menubar should be visible");
|
||||
}
|
||||
}
|
||||
|
||||
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();
|
||||
});
|
|
@ -1,13 +0,0 @@
|
|||
XPCOMUtils.defineLazyModuleGetter(this, "ReadingList",
|
||||
"resource:///modules/readinglist/ReadingList.jsm");
|
||||
XPCOMUtils.defineLazyModuleGetter(this, "ReadingListTestUtils",
|
||||
"resource://testing-common/ReadingListTestUtils.jsm");
|
||||
|
||||
|
||||
XPCOMUtils.defineLazyGetter(this, "RLUtils", () => {
|
||||
return ReadingListTestUtils;
|
||||
});
|
||||
|
||||
XPCOMUtils.defineLazyGetter(this, "RLSidebarUtils", () => {
|
||||
return new RLUtils.SidebarUtils(window, Assert);
|
||||
});
|
|
@ -1,56 +0,0 @@
|
|||
/* Any copyright is dedicated to the Public Domain.
|
||||
http://creativecommons.org/publicdomain/zero/1.0/ */
|
||||
|
||||
const {classes: Cc, interfaces: Ci, utils: Cu, results: Cr} = Components;
|
||||
|
||||
Cu.import("resource://gre/modules/XPCOMUtils.jsm");
|
||||
Cu.import("resource://gre/modules/Services.jsm");
|
||||
|
||||
do_get_profile(); // fxa needs a profile directory for storage.
|
||||
|
||||
Cu.import("resource://gre/modules/FxAccounts.jsm");
|
||||
Cu.import("resource://gre/modules/FxAccountsClient.jsm");
|
||||
|
||||
// Create a mocked FxAccounts object with a signed-in, verified user.
|
||||
function* createMockFxA() {
|
||||
|
||||
function MockFxAccountsClient() {
|
||||
this._email = "nobody@example.com";
|
||||
this._verified = false;
|
||||
|
||||
this.accountStatus = function(uid) {
|
||||
let deferred = Promise.defer();
|
||||
deferred.resolve(!!uid && (!this._deletedOnServer));
|
||||
return deferred.promise;
|
||||
};
|
||||
|
||||
this.signOut = function() { return Promise.resolve(); };
|
||||
|
||||
FxAccountsClient.apply(this);
|
||||
}
|
||||
|
||||
MockFxAccountsClient.prototype = {
|
||||
__proto__: FxAccountsClient.prototype
|
||||
}
|
||||
|
||||
function MockFxAccounts() {
|
||||
return new FxAccounts({
|
||||
fxAccountsClient: new MockFxAccountsClient(),
|
||||
getAssertion: () => Promise.resolve("assertion"),
|
||||
});
|
||||
}
|
||||
|
||||
let fxa = new MockFxAccounts();
|
||||
let credentials = {
|
||||
email: "foo@example.com",
|
||||
uid: "1234@lcip.org",
|
||||
assertion: "foobar",
|
||||
sessionToken: "dead",
|
||||
kA: "beef",
|
||||
kB: "cafe",
|
||||
verified: true
|
||||
};
|
||||
|
||||
yield fxa.setSignedInUser(credentials);
|
||||
return fxa;
|
||||
}
|
|
@ -1,782 +0,0 @@
|
|||
/* Any copyright is dedicated to the Public Domain.
|
||||
* http://creativecommons.org/publicdomain/zero/1.0/ */
|
||||
|
||||
"use strict";
|
||||
|
||||
let gDBFile = do_get_profile();
|
||||
|
||||
Cu.import("resource:///modules/readinglist/ReadingList.jsm");
|
||||
Cu.import("resource:///modules/readinglist/SQLiteStore.jsm");
|
||||
Cu.import("resource://gre/modules/Sqlite.jsm");
|
||||
Cu.import("resource://gre/modules/Timer.jsm");
|
||||
Cu.import("resource://gre/modules/Log.jsm");
|
||||
|
||||
Log.repository.getLogger("readinglist.api").level = Log.Level.All;
|
||||
Log.repository.getLogger("readinglist.api").addAppender(new Log.DumpAppender());
|
||||
|
||||
var gList;
|
||||
var gItems;
|
||||
|
||||
function run_test() {
|
||||
run_next_test();
|
||||
}
|
||||
|
||||
add_task(function* prepare() {
|
||||
gList = ReadingList;
|
||||
Assert.ok(gList);
|
||||
gDBFile.append(gList._store.pathRelativeToProfileDir);
|
||||
do_register_cleanup(function* () {
|
||||
// Wait for the list's store to close its connection to the database.
|
||||
yield gList.destroy();
|
||||
if (gDBFile.exists()) {
|
||||
gDBFile.remove(true);
|
||||
}
|
||||
});
|
||||
|
||||
gItems = [];
|
||||
for (let i = 0; i < 3; i++) {
|
||||
gItems.push({
|
||||
guid: `guid${i}`,
|
||||
url: `http://example.com/${i}`,
|
||||
resolvedURL: `http://example.com/resolved/${i}`,
|
||||
title: `title ${i}`,
|
||||
excerpt: `excerpt ${i}`,
|
||||
unread: 0,
|
||||
favorite: 0,
|
||||
isArticle: 1,
|
||||
storedOn: Date.now(),
|
||||
});
|
||||
}
|
||||
|
||||
for (let item of gItems) {
|
||||
let addedItem = yield gList.addItem(item);
|
||||
checkItems(addedItem, item);
|
||||
}
|
||||
});
|
||||
|
||||
add_task(function* item_properties() {
|
||||
// get an item
|
||||
let iter = gList.iterator({
|
||||
sort: "guid",
|
||||
});
|
||||
let item = (yield iter.items(1))[0];
|
||||
Assert.ok(item);
|
||||
|
||||
Assert.ok(item.uri);
|
||||
Assert.ok(item.uri instanceof Ci.nsIURI);
|
||||
Assert.equal(item.uri.spec, item._record.url);
|
||||
|
||||
Assert.ok(item.resolvedURI);
|
||||
Assert.ok(item.resolvedURI instanceof Ci.nsIURI);
|
||||
Assert.equal(item.resolvedURI.spec, item._record.resolvedURL);
|
||||
|
||||
Assert.ok(item.addedOn);
|
||||
Assert.ok(item.addedOn instanceof Cu.getGlobalForObject(ReadingList).Date);
|
||||
|
||||
Assert.ok(item.storedOn);
|
||||
Assert.ok(item.storedOn instanceof Cu.getGlobalForObject(ReadingList).Date);
|
||||
|
||||
Assert.ok(typeof(item.favorite) == "boolean");
|
||||
Assert.ok(typeof(item.isArticle) == "boolean");
|
||||
Assert.ok(typeof(item.unread) == "boolean");
|
||||
|
||||
Assert.equal(item.id, hash(item._record.url));
|
||||
});
|
||||
|
||||
add_task(function* constraints() {
|
||||
// add an item again
|
||||
let err = null;
|
||||
try {
|
||||
yield gList.addItem(gItems[0]);
|
||||
}
|
||||
catch (e) {
|
||||
err = e;
|
||||
}
|
||||
Assert.ok(err);
|
||||
Assert.ok(err instanceof ReadingList.Error.Exists);
|
||||
|
||||
// add a new item with an existing guid
|
||||
let item = kindOfClone(gItems[0]);
|
||||
item.guid = gItems[0].guid;
|
||||
err = null;
|
||||
try {
|
||||
yield gList.addItem(item);
|
||||
}
|
||||
catch (e) {
|
||||
err = e;
|
||||
}
|
||||
Assert.ok(err);
|
||||
Assert.ok(err instanceof ReadingList.Error.Exists);
|
||||
|
||||
// add a new item with an existing url
|
||||
item = kindOfClone(gItems[0]);
|
||||
item.url = gItems[0].url;
|
||||
err = null;
|
||||
try {
|
||||
yield gList.addItem(item);
|
||||
}
|
||||
catch (e) {
|
||||
err = e;
|
||||
}
|
||||
Assert.ok(err);
|
||||
Assert.ok(err instanceof ReadingList.Error.Exists);
|
||||
|
||||
// add a new item with an existing resolvedURL
|
||||
item = kindOfClone(gItems[0]);
|
||||
item.resolvedURL = gItems[0].resolvedURL;
|
||||
err = null;
|
||||
try {
|
||||
yield gList.addItem(item);
|
||||
}
|
||||
catch (e) {
|
||||
err = e;
|
||||
}
|
||||
Assert.ok(err);
|
||||
Assert.ok(err instanceof ReadingList.Error.Exists);
|
||||
|
||||
// add a new item with no url
|
||||
item = kindOfClone(gItems[0]);
|
||||
delete item.url;
|
||||
err = null;
|
||||
try {
|
||||
yield gList.addItem(item);
|
||||
}
|
||||
catch (e) {
|
||||
err = e;
|
||||
}
|
||||
Assert.ok(err);
|
||||
Assert.ok(err instanceof ReadingList.Error.Error);
|
||||
Assert.ok(!(err instanceof ReadingList.Error.Exists));
|
||||
Assert.ok(!(err instanceof ReadingList.Error.Deleted));
|
||||
|
||||
// update an item with no url
|
||||
item = (yield gList.item({ guid: gItems[0].guid }));
|
||||
Assert.ok(item);
|
||||
let oldURL = item._record.url;
|
||||
item._record.url = null;
|
||||
err = null;
|
||||
try {
|
||||
yield gList.updateItem(item);
|
||||
}
|
||||
catch (e) {
|
||||
err = e;
|
||||
}
|
||||
item._record.url = oldURL;
|
||||
Assert.ok(err);
|
||||
Assert.ok(err instanceof ReadingList.Error.Error);
|
||||
Assert.ok(!(err instanceof ReadingList.Error.Exists));
|
||||
Assert.ok(!(err instanceof ReadingList.Error.Deleted));
|
||||
|
||||
// add an item with a bogus property
|
||||
item = kindOfClone(gItems[0]);
|
||||
item.bogus = "gnarly";
|
||||
err = null;
|
||||
try {
|
||||
yield gList.addItem(item);
|
||||
}
|
||||
catch (e) {
|
||||
err = e;
|
||||
}
|
||||
Assert.ok(err);
|
||||
Assert.ok(err instanceof ReadingList.Error.Error);
|
||||
Assert.ok(!(err instanceof ReadingList.Error.Exists));
|
||||
Assert.ok(!(err instanceof ReadingList.Error.Deleted));
|
||||
|
||||
// add a new item with no guid, which is allowed
|
||||
item = kindOfClone(gItems[0]);
|
||||
delete item.guid;
|
||||
err = null;
|
||||
let rlitem1;
|
||||
try {
|
||||
rlitem1 = yield gList.addItem(item);
|
||||
}
|
||||
catch (e) {
|
||||
err = e;
|
||||
}
|
||||
Assert.ok(!err, err ? err.message : undefined);
|
||||
|
||||
// add a second item with no guid, which is allowed
|
||||
item = kindOfClone(gItems[1]);
|
||||
delete item.guid;
|
||||
err = null;
|
||||
let rlitem2;
|
||||
try {
|
||||
rlitem2 = yield gList.addItem(item);
|
||||
}
|
||||
catch (e) {
|
||||
err = e;
|
||||
}
|
||||
Assert.ok(!err, err ? err.message : undefined);
|
||||
|
||||
// Delete the two previous items since other tests assume the store contains
|
||||
// only gItems.
|
||||
yield gList.deleteItem(rlitem1);
|
||||
yield gList.deleteItem(rlitem2);
|
||||
let items = [];
|
||||
yield gList.forEachItem(i => items.push(i), { url: [rlitem1.uri.spec, rlitem2.uri.spec] });
|
||||
Assert.equal(items.length, 0);
|
||||
});
|
||||
|
||||
add_task(function* count() {
|
||||
let count = yield gList.count();
|
||||
Assert.equal(count, gItems.length);
|
||||
|
||||
count = yield gList.count({
|
||||
guid: gItems[0].guid,
|
||||
});
|
||||
Assert.equal(count, 1);
|
||||
});
|
||||
|
||||
add_task(function* forEachItem() {
|
||||
// all items
|
||||
let items = [];
|
||||
yield gList.forEachItem(item => items.push(item), {
|
||||
sort: "guid",
|
||||
});
|
||||
checkItems(items, gItems);
|
||||
|
||||
// first item
|
||||
items = [];
|
||||
yield gList.forEachItem(item => items.push(item), {
|
||||
limit: 1,
|
||||
sort: "guid",
|
||||
});
|
||||
checkItems(items, gItems.slice(0, 1));
|
||||
|
||||
// last item
|
||||
items = [];
|
||||
yield gList.forEachItem(item => items.push(item), {
|
||||
limit: 1,
|
||||
sort: "guid",
|
||||
descending: true,
|
||||
});
|
||||
checkItems(items, gItems.slice(gItems.length - 1, gItems.length));
|
||||
|
||||
// match on a scalar property
|
||||
items = [];
|
||||
yield gList.forEachItem(item => items.push(item), {
|
||||
guid: gItems[0].guid,
|
||||
});
|
||||
checkItems(items, gItems.slice(0, 1));
|
||||
|
||||
// match on an array
|
||||
items = [];
|
||||
yield gList.forEachItem(item => items.push(item), {
|
||||
guid: gItems.map(i => i.guid),
|
||||
sort: "guid",
|
||||
});
|
||||
checkItems(items, gItems);
|
||||
|
||||
// match on AND'ed properties
|
||||
items = [];
|
||||
yield gList.forEachItem(item => items.push(item), {
|
||||
guid: gItems.map(i => i.guid),
|
||||
title: gItems[0].title,
|
||||
sort: "guid",
|
||||
});
|
||||
checkItems(items, [gItems[0]]);
|
||||
|
||||
// match on OR'ed properties
|
||||
items = [];
|
||||
yield gList.forEachItem(item => items.push(item), {
|
||||
guid: gItems[1].guid,
|
||||
sort: "guid",
|
||||
}, {
|
||||
guid: gItems[0].guid,
|
||||
});
|
||||
checkItems(items, [gItems[0], gItems[1]]);
|
||||
|
||||
// match on AND'ed and OR'ed properties
|
||||
items = [];
|
||||
yield gList.forEachItem(item => items.push(item), {
|
||||
guid: gItems.map(i => i.guid),
|
||||
title: gItems[1].title,
|
||||
sort: "guid",
|
||||
}, {
|
||||
guid: gItems[0].guid,
|
||||
});
|
||||
checkItems(items, [gItems[0], gItems[1]]);
|
||||
});
|
||||
|
||||
add_task(function* forEachSyncedDeletedItem() {
|
||||
let deletedItem = yield gList.addItem({
|
||||
guid: "forEachSyncedDeletedItem",
|
||||
url: "http://example.com/forEachSyncedDeletedItem",
|
||||
});
|
||||
deletedItem._record.syncStatus = gList.SyncStatus.SYNCED;
|
||||
yield gList.deleteItem(deletedItem);
|
||||
let guids = [];
|
||||
yield gList.forEachSyncedDeletedGUID(guid => guids.push(guid));
|
||||
Assert.equal(guids.length, 1);
|
||||
Assert.equal(guids[0], deletedItem.guid);
|
||||
});
|
||||
|
||||
add_task(function* forEachItem_promises() {
|
||||
// promises resolved immediately
|
||||
let items = [];
|
||||
yield gList.forEachItem(item => {
|
||||
items.push(item);
|
||||
return Promise.resolve();
|
||||
}, {
|
||||
sort: "guid",
|
||||
});
|
||||
checkItems(items, gItems);
|
||||
|
||||
// promises resolved after a delay
|
||||
items = [];
|
||||
let i = 0;
|
||||
let promises = [];
|
||||
yield gList.forEachItem(item => {
|
||||
items.push(item);
|
||||
// The previous promise should have been resolved by now.
|
||||
if (i > 0) {
|
||||
Assert.equal(promises[i - 1], null);
|
||||
}
|
||||
// Make a new promise that should continue iteration when resolved.
|
||||
let this_i = i++;
|
||||
let promise = new Promise(resolve => {
|
||||
// Resolve the promise one second from now. The idea is that if
|
||||
// forEachItem works correctly, then the callback should not be called
|
||||
// again before the promise resolves -- before one second elapases.
|
||||
// Maybe there's a better way to do this that doesn't hinge on timeouts.
|
||||
setTimeout(() => {
|
||||
promises[this_i] = null;
|
||||
resolve();
|
||||
}, 0);
|
||||
});
|
||||
promises.push(promise);
|
||||
return promise;
|
||||
}, {
|
||||
sort: "guid",
|
||||
});
|
||||
checkItems(items, gItems);
|
||||
});
|
||||
|
||||
add_task(function* iterator_forEach() {
|
||||
// no limit
|
||||
let items = [];
|
||||
let iter = gList.iterator({
|
||||
sort: "guid",
|
||||
});
|
||||
yield iter.forEach(item => items.push(item));
|
||||
checkItems(items, gItems);
|
||||
|
||||
// limit one each time
|
||||
items = [];
|
||||
iter = gList.iterator({
|
||||
sort: "guid",
|
||||
});
|
||||
for (let i = 0; i < gItems.length; i++) {
|
||||
yield iter.forEach(item => items.push(item), 1);
|
||||
checkItems(items, gItems.slice(0, i + 1));
|
||||
}
|
||||
yield iter.forEach(item => items.push(item), 100);
|
||||
checkItems(items, gItems);
|
||||
yield iter.forEach(item => items.push(item));
|
||||
checkItems(items, gItems);
|
||||
|
||||
// match on a scalar property
|
||||
items = [];
|
||||
iter = gList.iterator({
|
||||
sort: "guid",
|
||||
guid: gItems[0].guid,
|
||||
});
|
||||
yield iter.forEach(item => items.push(item));
|
||||
checkItems(items, [gItems[0]]);
|
||||
|
||||
// match on an array
|
||||
items = [];
|
||||
iter = gList.iterator({
|
||||
sort: "guid",
|
||||
guid: gItems.map(i => i.guid),
|
||||
});
|
||||
yield iter.forEach(item => items.push(item));
|
||||
checkItems(items, gItems);
|
||||
|
||||
// match on AND'ed properties
|
||||
items = [];
|
||||
iter = gList.iterator({
|
||||
sort: "guid",
|
||||
guid: gItems.map(i => i.guid),
|
||||
title: gItems[0].title,
|
||||
});
|
||||
yield iter.forEach(item => items.push(item));
|
||||
checkItems(items, [gItems[0]]);
|
||||
|
||||
// match on OR'ed properties
|
||||
items = [];
|
||||
iter = gList.iterator({
|
||||
sort: "guid",
|
||||
guid: gItems[1].guid,
|
||||
}, {
|
||||
guid: gItems[0].guid,
|
||||
});
|
||||
yield iter.forEach(item => items.push(item));
|
||||
checkItems(items, [gItems[0], gItems[1]]);
|
||||
|
||||
// match on AND'ed and OR'ed properties
|
||||
items = [];
|
||||
iter = gList.iterator({
|
||||
sort: "guid",
|
||||
guid: gItems.map(i => i.guid),
|
||||
title: gItems[1].title,
|
||||
}, {
|
||||
guid: gItems[0].guid,
|
||||
});
|
||||
yield iter.forEach(item => items.push(item));
|
||||
checkItems(items, [gItems[0], gItems[1]]);
|
||||
});
|
||||
|
||||
add_task(function* iterator_items() {
|
||||
// no limit
|
||||
let iter = gList.iterator({
|
||||
sort: "guid",
|
||||
});
|
||||
let items = yield iter.items(gItems.length);
|
||||
checkItems(items, gItems);
|
||||
items = yield iter.items(100);
|
||||
checkItems(items, []);
|
||||
|
||||
// limit one each time
|
||||
iter = gList.iterator({
|
||||
sort: "guid",
|
||||
});
|
||||
for (let i = 0; i < gItems.length; i++) {
|
||||
items = yield iter.items(1);
|
||||
checkItems(items, gItems.slice(i, i + 1));
|
||||
}
|
||||
items = yield iter.items(100);
|
||||
checkItems(items, []);
|
||||
|
||||
// match on a scalar property
|
||||
iter = gList.iterator({
|
||||
sort: "guid",
|
||||
guid: gItems[0].guid,
|
||||
});
|
||||
items = yield iter.items(gItems.length);
|
||||
checkItems(items, [gItems[0]]);
|
||||
|
||||
// match on an array
|
||||
iter = gList.iterator({
|
||||
sort: "guid",
|
||||
guid: gItems.map(i => i.guid),
|
||||
});
|
||||
items = yield iter.items(gItems.length);
|
||||
checkItems(items, gItems);
|
||||
|
||||
// match on AND'ed properties
|
||||
iter = gList.iterator({
|
||||
sort: "guid",
|
||||
guid: gItems.map(i => i.guid),
|
||||
title: gItems[0].title,
|
||||
});
|
||||
items = yield iter.items(gItems.length);
|
||||
checkItems(items, [gItems[0]]);
|
||||
|
||||
// match on OR'ed properties
|
||||
iter = gList.iterator({
|
||||
sort: "guid",
|
||||
guid: gItems[1].guid,
|
||||
}, {
|
||||
guid: gItems[0].guid,
|
||||
});
|
||||
items = yield iter.items(gItems.length);
|
||||
checkItems(items, [gItems[0], gItems[1]]);
|
||||
|
||||
// match on AND'ed and OR'ed properties
|
||||
iter = gList.iterator({
|
||||
sort: "guid",
|
||||
guid: gItems.map(i => i.guid),
|
||||
title: gItems[1].title,
|
||||
}, {
|
||||
guid: gItems[0].guid,
|
||||
});
|
||||
items = yield iter.items(gItems.length);
|
||||
checkItems(items, [gItems[0], gItems[1]]);
|
||||
});
|
||||
|
||||
add_task(function* iterator_forEach_promise() {
|
||||
// promises resolved immediately
|
||||
let items = [];
|
||||
let iter = gList.iterator({
|
||||
sort: "guid",
|
||||
});
|
||||
yield iter.forEach(item => {
|
||||
items.push(item);
|
||||
return Promise.resolve();
|
||||
});
|
||||
checkItems(items, gItems);
|
||||
|
||||
// promises resolved after a delay
|
||||
// See forEachItem_promises above for comments on this part.
|
||||
items = [];
|
||||
let i = 0;
|
||||
let promises = [];
|
||||
iter = gList.iterator({
|
||||
sort: "guid",
|
||||
});
|
||||
yield iter.forEach(item => {
|
||||
items.push(item);
|
||||
if (i > 0) {
|
||||
Assert.equal(promises[i - 1], null);
|
||||
}
|
||||
let this_i = i++;
|
||||
let promise = new Promise(resolve => {
|
||||
setTimeout(() => {
|
||||
promises[this_i] = null;
|
||||
resolve();
|
||||
}, 0);
|
||||
});
|
||||
promises.push(promise);
|
||||
return promise;
|
||||
});
|
||||
checkItems(items, gItems);
|
||||
});
|
||||
|
||||
add_task(function* item() {
|
||||
let item = yield gList.item({ guid: gItems[0].guid });
|
||||
checkItems([item], [gItems[0]]);
|
||||
|
||||
item = yield gList.item({ guid: gItems[1].guid });
|
||||
checkItems([item], [gItems[1]]);
|
||||
});
|
||||
|
||||
add_task(function* itemForURL() {
|
||||
let item = yield gList.itemForURL(gItems[0].url);
|
||||
checkItems([item], [gItems[0]]);
|
||||
|
||||
item = yield gList.itemForURL(gItems[1].url);
|
||||
checkItems([item], [gItems[1]]);
|
||||
});
|
||||
|
||||
add_task(function* updateItem() {
|
||||
// get an item
|
||||
let items = [];
|
||||
yield gList.forEachItem(i => items.push(i), {
|
||||
guid: gItems[0].guid,
|
||||
});
|
||||
Assert.equal(items.length, 1);
|
||||
let item = items[0];
|
||||
|
||||
// update its title
|
||||
let newTitle = "updateItem new title";
|
||||
Assert.notEqual(item.title, newTitle);
|
||||
item.title = newTitle;
|
||||
yield gList.updateItem(item);
|
||||
|
||||
// get the item again
|
||||
items = [];
|
||||
yield gList.forEachItem(i => items.push(i), {
|
||||
guid: gItems[0].guid,
|
||||
});
|
||||
Assert.equal(items.length, 1);
|
||||
item = items[0];
|
||||
Assert.equal(item.title, newTitle);
|
||||
});
|
||||
|
||||
add_task(function* item_setRecord() {
|
||||
// get an item
|
||||
let iter = gList.iterator({
|
||||
sort: "guid",
|
||||
});
|
||||
let item = (yield iter.items(1))[0];
|
||||
Assert.ok(item);
|
||||
|
||||
// Set item._record followed by an updateItem. After fetching the item again,
|
||||
// its title should be the new title.
|
||||
let newTitle = "item_setRecord title 1";
|
||||
item._record.title = newTitle;
|
||||
yield gList.updateItem(item);
|
||||
Assert.equal(item.title, newTitle);
|
||||
iter = gList.iterator({
|
||||
sort: "guid",
|
||||
});
|
||||
let sameItem = (yield iter.items(1))[0];
|
||||
Assert.ok(item === sameItem);
|
||||
Assert.equal(sameItem.title, newTitle);
|
||||
|
||||
// Set item.title directly and call updateItem. After fetching the item
|
||||
// again, its title should be the new title.
|
||||
newTitle = "item_setRecord title 2";
|
||||
item.title = newTitle;
|
||||
yield gList.updateItem(item);
|
||||
Assert.equal(item.title, newTitle);
|
||||
iter = gList.iterator({
|
||||
sort: "guid",
|
||||
});
|
||||
sameItem = (yield iter.items(1))[0];
|
||||
Assert.ok(item === sameItem);
|
||||
Assert.equal(sameItem.title, newTitle);
|
||||
|
||||
// Setting _record to an object with a bogus property should throw.
|
||||
let err = null;
|
||||
try {
|
||||
item._record = { bogus: "gnarly" };
|
||||
}
|
||||
catch (e) {
|
||||
err = e;
|
||||
}
|
||||
Assert.ok(err);
|
||||
Assert.ok(err.message);
|
||||
Assert.ok(err.message.indexOf("Unrecognized item property:") >= 0);
|
||||
});
|
||||
|
||||
add_task(function* listeners() {
|
||||
Assert.equal((yield gList.count()), gItems.length);
|
||||
// add an item
|
||||
let resolve;
|
||||
let listenerPromise = new Promise(r => resolve = r);
|
||||
let listener = {
|
||||
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);
|
||||
Assert.equal((yield gList.count()), gItems.length + 1);
|
||||
|
||||
// update an item
|
||||
listenerPromise = new Promise(r => resolve = r);
|
||||
listener = {
|
||||
onItemUpdated: resolve,
|
||||
};
|
||||
gList.addListener(listener);
|
||||
items[0].title = "listeners new title";
|
||||
yield gList.updateItem(items[0]);
|
||||
let listenerItem = yield listenerPromise;
|
||||
Assert.ok(listenerItem);
|
||||
Assert.ok(listenerItem === items[0]);
|
||||
gList.removeListener(listener);
|
||||
Assert.equal((yield gList.count()), gItems.length + 1);
|
||||
|
||||
// delete an item
|
||||
listenerPromise = new Promise(r => resolve = r);
|
||||
listener = {
|
||||
onItemDeleted: resolve,
|
||||
};
|
||||
gList.addListener(listener);
|
||||
items[0].delete();
|
||||
listenerItem = yield listenerPromise;
|
||||
Assert.ok(listenerItem);
|
||||
Assert.ok(listenerItem === items[0]);
|
||||
gList.removeListener(listener);
|
||||
Assert.equal((yield gList.count()), gItems.length);
|
||||
});
|
||||
|
||||
// This test deletes items so it should probably run last of the 'gItems' tests...
|
||||
add_task(function* deleteItem() {
|
||||
// delete first item with item.delete()
|
||||
let iter = gList.iterator({
|
||||
sort: "guid",
|
||||
});
|
||||
let item = (yield iter.items(1))[0];
|
||||
Assert.ok(item);
|
||||
let {url, guid} = item;
|
||||
Assert.ok((yield gList.itemForURL(url)), "should be able to get the item by URL before deletion");
|
||||
Assert.ok((yield gList.item({guid})), "should be able to get the item by GUID before deletion");
|
||||
|
||||
yield item.delete();
|
||||
try {
|
||||
yield item.delete();
|
||||
Assert.ok(false, "should not successfully delete the item a second time")
|
||||
} catch(ex) {
|
||||
Assert.ok(ex instanceof ReadingList.Error.Deleted);
|
||||
}
|
||||
|
||||
Assert.ok(!(yield gList.itemForURL(url)), "should fail to get a deleted item by URL");
|
||||
Assert.ok(!(yield gList.item({guid})), "should fail to get a deleted item by GUID");
|
||||
|
||||
gItems[0].list = null;
|
||||
Assert.equal((yield gList.count()), gItems.length - 1);
|
||||
let items = [];
|
||||
yield gList.forEachItem(i => items.push(i), {
|
||||
sort: "guid",
|
||||
});
|
||||
checkItems(items, gItems.slice(1));
|
||||
|
||||
// delete second item with list.deleteItem()
|
||||
yield gList.deleteItem(items[0]);
|
||||
try {
|
||||
yield gList.deleteItem(items[0]);
|
||||
Assert.ok(false, "should not successfully delete the item a second time")
|
||||
} catch(ex) {
|
||||
Assert.ok(ex instanceof ReadingList.Error.Deleted);
|
||||
}
|
||||
gItems[1].list = null;
|
||||
Assert.equal((yield gList.count()), gItems.length - 2);
|
||||
items = [];
|
||||
yield gList.forEachItem(i => items.push(i), {
|
||||
sort: "guid",
|
||||
});
|
||||
checkItems(items, gItems.slice(2));
|
||||
|
||||
// delete third item with list.deleteItem()
|
||||
yield gList.deleteItem(items[0]);
|
||||
gItems[2].list = null;
|
||||
Assert.equal((yield gList.count()), gItems.length - 3);
|
||||
items = [];
|
||||
yield gList.forEachItem(i => items.push(i), {
|
||||
sort: "guid",
|
||||
});
|
||||
checkItems(items, gItems.slice(3));
|
||||
});
|
||||
|
||||
// Check that when we delete an item with a GUID it's no longer available as
|
||||
// an item
|
||||
add_task(function* deletedItemRemovedFromMap() {
|
||||
yield gList.forEachItem(item => item.delete());
|
||||
Assert.equal((yield gList.count()), 0);
|
||||
let map = gList._itemsByNormalizedURL;
|
||||
Assert.equal(gList._itemsByNormalizedURL.size, 0, [for (i of map.keys()) i]);
|
||||
let record = {
|
||||
guid: "test-item",
|
||||
url: "http://localhost",
|
||||
syncStatus: gList.SyncStatus.SYNCED,
|
||||
}
|
||||
let item = yield gList.addItem(record);
|
||||
Assert.equal(map.size, 1);
|
||||
yield item.delete();
|
||||
Assert.equal(gList._itemsByNormalizedURL.size, 0, [for (i of map.keys()) i]);
|
||||
|
||||
// Now enumerate deleted items - should not come back.
|
||||
yield gList.forEachSyncedDeletedGUID(() => {});
|
||||
Assert.equal(gList._itemsByNormalizedURL.size, 0, [for (i of map.keys()) i]);
|
||||
});
|
||||
|
||||
function checkItems(actualItems, expectedItems) {
|
||||
Assert.equal(actualItems.length, expectedItems.length);
|
||||
for (let i = 0; i < expectedItems.length; i++) {
|
||||
for (let prop in expectedItems[i]._record) {
|
||||
Assert.ok(prop in actualItems[i]._record, prop);
|
||||
Assert.equal(actualItems[i]._record[prop], expectedItems[i][prop]);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
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 binaryStr)].
|
||||
join("");
|
||||
return hexStr;
|
||||
}
|
|
@ -1,333 +0,0 @@
|
|||
/* Any copyright is dedicated to the Public Domain.
|
||||
* http://creativecommons.org/publicdomain/zero/1.0/ */
|
||||
|
||||
"use strict";
|
||||
|
||||
Cu.import("resource:///modules/readinglist/ReadingList.jsm");
|
||||
Cu.import("resource:///modules/readinglist/SQLiteStore.jsm");
|
||||
Cu.import("resource://gre/modules/Sqlite.jsm");
|
||||
|
||||
var gStore;
|
||||
var gItems;
|
||||
|
||||
function run_test() {
|
||||
run_next_test();
|
||||
}
|
||||
|
||||
add_task(function* prepare() {
|
||||
let basename = "reading-list-test.sqlite";
|
||||
let dbFile = do_get_profile();
|
||||
dbFile.append(basename);
|
||||
function removeDB() {
|
||||
if (dbFile.exists()) {
|
||||
dbFile.remove(true);
|
||||
}
|
||||
}
|
||||
removeDB();
|
||||
do_register_cleanup(function* () {
|
||||
// Wait for the store to close its connection to the database.
|
||||
yield gStore.destroy();
|
||||
removeDB();
|
||||
});
|
||||
|
||||
gStore = new SQLiteStore(dbFile.path);
|
||||
|
||||
gItems = [];
|
||||
for (let i = 0; i < 3; i++) {
|
||||
gItems.push({
|
||||
guid: `guid${i}`,
|
||||
url: `http://example.com/${i}`,
|
||||
resolvedURL: `http://example.com/resolved/${i}`,
|
||||
title: `title ${i}`,
|
||||
excerpt: `excerpt ${i}`,
|
||||
unread: true,
|
||||
addedOn: i,
|
||||
});
|
||||
}
|
||||
|
||||
for (let item of gItems) {
|
||||
yield gStore.addItem(item);
|
||||
}
|
||||
});
|
||||
|
||||
add_task(function* constraints() {
|
||||
// add an item again
|
||||
let err = null;
|
||||
try {
|
||||
yield gStore.addItem(gItems[0]);
|
||||
}
|
||||
catch (e) {
|
||||
err = e;
|
||||
}
|
||||
Assert.ok(err);
|
||||
Assert.ok(err instanceof ReadingList.Error.Exists);
|
||||
Assert.ok(err.message);
|
||||
Assert.ok(err.message.indexOf("An item with the following property already exists:") >= 0);
|
||||
|
||||
// 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;
|
||||
try {
|
||||
yield gStore.addItem(item);
|
||||
}
|
||||
catch (e) {
|
||||
err = e;
|
||||
}
|
||||
Assert.ok(err);
|
||||
Assert.ok(err instanceof ReadingList.Error.Exists);
|
||||
Assert.ok(err.message);
|
||||
Assert.ok(err.message.indexOf("An item with the following property already exists: guid") >= 0);
|
||||
|
||||
// add a new item with an existing url
|
||||
item = kindOfClone(gItems[0]);
|
||||
item.url = gItems[0].url;
|
||||
err = null;
|
||||
try {
|
||||
yield gStore.addItem(item);
|
||||
}
|
||||
catch (e) {
|
||||
err = e;
|
||||
}
|
||||
Assert.ok(err);
|
||||
Assert.ok(err instanceof ReadingList.Error.Exists);
|
||||
Assert.ok(err.message);
|
||||
Assert.ok(err.message.indexOf("An item with the following property already exists: url") >= 0);
|
||||
|
||||
// update an item with an existing url
|
||||
item.guid = gItems[1].guid;
|
||||
err = null;
|
||||
try {
|
||||
yield gStore.updateItem(item);
|
||||
}
|
||||
catch (e) {
|
||||
err = e;
|
||||
}
|
||||
// The failure actually happens on items.guid, not items.url, because the item
|
||||
// is first looked up by url, and then its other properties are updated on the
|
||||
// resulting row.
|
||||
Assert.ok(err);
|
||||
Assert.ok(err instanceof ReadingList.Error.Exists);
|
||||
Assert.ok(err.message);
|
||||
Assert.ok(err.message.indexOf("An item with the following property already exists: guid") >= 0);
|
||||
|
||||
// add a new item with an existing resolvedURL
|
||||
item = kindOfClone(gItems[0]);
|
||||
item.resolvedURL = gItems[0].resolvedURL;
|
||||
err = null;
|
||||
try {
|
||||
yield gStore.addItem(item);
|
||||
}
|
||||
catch (e) {
|
||||
err = e;
|
||||
}
|
||||
Assert.ok(err);
|
||||
Assert.ok(err instanceof ReadingList.Error.Exists);
|
||||
Assert.ok(err.message);
|
||||
Assert.ok(err.message.indexOf("An item with the following property already exists: resolvedURL") >= 0);
|
||||
|
||||
// update an item with an existing resolvedURL
|
||||
item.url = gItems[1].url;
|
||||
err = null;
|
||||
try {
|
||||
yield gStore.updateItem(item);
|
||||
}
|
||||
catch (e) {
|
||||
err = e;
|
||||
}
|
||||
Assert.ok(err);
|
||||
Assert.ok(err instanceof ReadingList.Error.Exists);
|
||||
Assert.ok(err.message);
|
||||
Assert.ok(err.message.indexOf("An item with the following property already exists: resolvedURL") >= 0);
|
||||
|
||||
// add a new item with no guid, which is allowed
|
||||
item = kindOfClone(gItems[0]);
|
||||
delete item.guid;
|
||||
err = null;
|
||||
try {
|
||||
yield gStore.addItem(item);
|
||||
}
|
||||
catch (e) {
|
||||
err = e;
|
||||
}
|
||||
Assert.ok(!err, err ? err.message : undefined);
|
||||
let url1 = item.url;
|
||||
|
||||
// add a second new item with no guid, which is allowed
|
||||
item = kindOfClone(gItems[1]);
|
||||
delete item.guid;
|
||||
err = null;
|
||||
try {
|
||||
yield gStore.addItem(item);
|
||||
}
|
||||
catch (e) {
|
||||
err = e;
|
||||
}
|
||||
Assert.ok(!err, err ? err.message : undefined);
|
||||
let url2 = item.url;
|
||||
|
||||
// Delete both items since other tests assume the store contains only gItems.
|
||||
yield gStore.deleteItemByURL(url1);
|
||||
yield gStore.deleteItemByURL(url2);
|
||||
let items = [];
|
||||
yield gStore.forEachItem(i => items.push(i), [{ url: [url1, url2] }]);
|
||||
Assert.equal(items.length, 0);
|
||||
});
|
||||
|
||||
add_task(function* count() {
|
||||
let count = yield gStore.count();
|
||||
Assert.equal(count, gItems.length);
|
||||
|
||||
count = yield gStore.count([{
|
||||
guid: gItems[0].guid,
|
||||
}]);
|
||||
Assert.equal(count, 1);
|
||||
});
|
||||
|
||||
add_task(function* forEachItem() {
|
||||
// all items
|
||||
let items = [];
|
||||
yield gStore.forEachItem(item => items.push(item), [{
|
||||
sort: "guid",
|
||||
}]);
|
||||
checkItems(items, gItems);
|
||||
|
||||
// first item
|
||||
items = [];
|
||||
yield gStore.forEachItem(item => items.push(item), [{
|
||||
limit: 1,
|
||||
sort: "guid",
|
||||
}]);
|
||||
checkItems(items, gItems.slice(0, 1));
|
||||
|
||||
// last item
|
||||
items = [];
|
||||
yield gStore.forEachItem(item => items.push(item), [{
|
||||
limit: 1,
|
||||
sort: "guid",
|
||||
descending: true,
|
||||
}]);
|
||||
checkItems(items, gItems.slice(gItems.length - 1, gItems.length));
|
||||
|
||||
// match on a scalar property
|
||||
items = [];
|
||||
yield gStore.forEachItem(item => items.push(item), [{
|
||||
guid: gItems[0].guid,
|
||||
}]);
|
||||
checkItems(items, gItems.slice(0, 1));
|
||||
|
||||
// match on an array
|
||||
items = [];
|
||||
yield gStore.forEachItem(item => items.push(item), [{
|
||||
guid: gItems.map(i => i.guid),
|
||||
sort: "guid",
|
||||
}]);
|
||||
checkItems(items, gItems);
|
||||
|
||||
// match on AND'ed properties
|
||||
items = [];
|
||||
yield gStore.forEachItem(item => items.push(item), [{
|
||||
guid: gItems.map(i => i.guid),
|
||||
title: gItems[0].title,
|
||||
sort: "guid",
|
||||
}]);
|
||||
checkItems(items, [gItems[0]]);
|
||||
|
||||
// match on OR'ed properties
|
||||
items = [];
|
||||
yield gStore.forEachItem(item => items.push(item), [{
|
||||
guid: gItems[1].guid,
|
||||
sort: "guid",
|
||||
}, {
|
||||
guid: gItems[0].guid,
|
||||
}]);
|
||||
checkItems(items, [gItems[0], gItems[1]]);
|
||||
|
||||
// match on AND'ed and OR'ed properties
|
||||
items = [];
|
||||
yield gStore.forEachItem(item => items.push(item), [{
|
||||
guid: gItems.map(i => i.guid),
|
||||
title: gItems[1].title,
|
||||
sort: "guid",
|
||||
}, {
|
||||
guid: gItems[0].guid,
|
||||
}]);
|
||||
checkItems(items, [gItems[0], gItems[1]]);
|
||||
});
|
||||
|
||||
add_task(function* updateItem() {
|
||||
let newTitle = "a new title";
|
||||
gItems[0].title = newTitle;
|
||||
yield gStore.updateItem(gItems[0]);
|
||||
let item;
|
||||
yield gStore.forEachItem(i => item = i, [{
|
||||
guid: gItems[0].guid,
|
||||
}]);
|
||||
Assert.ok(item);
|
||||
Assert.equal(item.title, gItems[0].title);
|
||||
});
|
||||
|
||||
add_task(function* updateItemByGUID() {
|
||||
let newTitle = "updateItemByGUID";
|
||||
gItems[0].title = newTitle;
|
||||
yield gStore.updateItemByGUID(gItems[0]);
|
||||
let item;
|
||||
yield gStore.forEachItem(i => item = i, [{
|
||||
guid: gItems[0].guid,
|
||||
}]);
|
||||
Assert.ok(item);
|
||||
Assert.equal(item.title, gItems[0].title);
|
||||
});
|
||||
|
||||
// This test deletes items so it should probably run last.
|
||||
add_task(function* deleteItemByURL() {
|
||||
// delete first item
|
||||
yield gStore.deleteItemByURL(gItems[0].url);
|
||||
Assert.equal((yield gStore.count()), gItems.length - 1);
|
||||
let items = [];
|
||||
yield gStore.forEachItem(i => items.push(i), [{
|
||||
sort: "guid",
|
||||
}]);
|
||||
checkItems(items, gItems.slice(1));
|
||||
|
||||
// delete second item
|
||||
yield gStore.deleteItemByURL(gItems[1].url);
|
||||
Assert.equal((yield gStore.count()), gItems.length - 2);
|
||||
items = [];
|
||||
yield gStore.forEachItem(i => items.push(i), [{
|
||||
sort: "guid",
|
||||
}]);
|
||||
checkItems(items, gItems.slice(2));
|
||||
});
|
||||
|
||||
// This test deletes items so it should probably run last.
|
||||
add_task(function* deleteItemByGUID() {
|
||||
// delete third item
|
||||
yield gStore.deleteItemByGUID(gItems[2].guid);
|
||||
Assert.equal((yield gStore.count()), gItems.length - 3);
|
||||
let items = [];
|
||||
yield gStore.forEachItem(i => items.push(i), [{
|
||||
sort: "guid",
|
||||
}]);
|
||||
checkItems(items, gItems.slice(3));
|
||||
});
|
||||
|
||||
function checkItems(actualItems, expectedItems) {
|
||||
Assert.equal(actualItems.length, expectedItems.length);
|
||||
for (let i = 0; i < expectedItems.length; i++) {
|
||||
for (let prop in expectedItems[i]) {
|
||||
Assert.ok(prop in actualItems[i], prop);
|
||||
Assert.equal(actualItems[i][prop], expectedItems[i][prop]);
|
||||
}
|
||||
}
|
||||
}
|
|
@ -1,285 +0,0 @@
|
|||
/* Any copyright is dedicated to the Public Domain.
|
||||
* http://creativecommons.org/publicdomain/zero/1.0/ */
|
||||
|
||||
"use strict";
|
||||
|
||||
Cu.import("resource://testing-common/httpd.js");
|
||||
Cu.import("resource:///modules/readinglist/ServerClient.jsm");
|
||||
Cu.import("resource://gre/modules/Log.jsm");
|
||||
|
||||
let appender = new Log.DumpAppender();
|
||||
for (let logName of ["FirefoxAccounts", "readinglist.serverclient"]) {
|
||||
Log.repository.getLogger(logName).addAppender(appender);
|
||||
}
|
||||
|
||||
// Some test servers we use.
|
||||
let Server = function(handlers) {
|
||||
this._server = null;
|
||||
this._handlers = handlers;
|
||||
}
|
||||
|
||||
Server.prototype = {
|
||||
start() {
|
||||
this._server = new HttpServer();
|
||||
for (let [path, handler] in Iterator(this._handlers)) {
|
||||
// httpd.js seems to swallow exceptions
|
||||
let thisHandler = handler;
|
||||
let wrapper = (request, response) => {
|
||||
try {
|
||||
thisHandler(request, response);
|
||||
} catch (ex) {
|
||||
print("**** Handler for", path, "failed:", ex, ex.stack);
|
||||
throw ex;
|
||||
}
|
||||
}
|
||||
this._server.registerPathHandler(path, wrapper);
|
||||
}
|
||||
this._server.start(-1);
|
||||
},
|
||||
|
||||
stop() {
|
||||
return new Promise(resolve => {
|
||||
this._server.stop(resolve);
|
||||
this._server = null;
|
||||
});
|
||||
},
|
||||
|
||||
get host() {
|
||||
return "http://localhost:" + this._server.identity.primaryPort;
|
||||
},
|
||||
};
|
||||
|
||||
// An OAuth server that hands out tokens.
|
||||
function OAuthTokenServer() {
|
||||
let server;
|
||||
let handlers = {
|
||||
"/v1/authorization": (request, response) => {
|
||||
response.setStatusLine("1.1", 200, "OK");
|
||||
let token = "token" + server.numTokenFetches;
|
||||
print("Test OAuth server handing out token", token);
|
||||
server.numTokenFetches += 1;
|
||||
server.activeTokens.add(token);
|
||||
response.write(JSON.stringify({access_token: token}));
|
||||
},
|
||||
"/v1/destroy": (request, response) => {
|
||||
// Getting the body seems harder than it should be!
|
||||
let sis = Cc["@mozilla.org/scriptableinputstream;1"]
|
||||
.createInstance(Ci.nsIScriptableInputStream);
|
||||
sis.init(request.bodyInputStream);
|
||||
let body = JSON.parse(sis.read(sis.available()));
|
||||
sis.close();
|
||||
let token = body.token;
|
||||
ok(server.activeTokens.delete(token));
|
||||
print("after destroy have", server.activeTokens.size, "tokens left.")
|
||||
response.setStatusLine("1.1", 200, "OK");
|
||||
response.write('{}');
|
||||
},
|
||||
}
|
||||
server = new Server(handlers);
|
||||
server.numTokenFetches = 0;
|
||||
server.activeTokens = new Set();
|
||||
return server;
|
||||
}
|
||||
|
||||
function promiseObserver(topic) {
|
||||
return new Promise(resolve => {
|
||||
function observe(subject, topic, data) {
|
||||
Services.obs.removeObserver(observe, topic);
|
||||
resolve(data);
|
||||
}
|
||||
Services.obs.addObserver(observe, topic, false);
|
||||
});
|
||||
}
|
||||
|
||||
// The tests.
|
||||
function run_test() {
|
||||
run_next_test();
|
||||
}
|
||||
|
||||
// Arrange for the first token we hand out to be rejected - the client should
|
||||
// notice the 401 and silently get a new token and retry the request.
|
||||
add_task(function testAuthRetry() {
|
||||
let handlers = {
|
||||
"/v1/batch": (request, response) => {
|
||||
// We know the first token we will get is "token0", so we simulate that
|
||||
// "expiring" by only accepting "token1". Then we just echo the response
|
||||
// back.
|
||||
let authHeader;
|
||||
try {
|
||||
authHeader = request.getHeader("Authorization");
|
||||
} catch (ex) {}
|
||||
if (authHeader != "Bearer token1") {
|
||||
response.setStatusLine("1.1", 401, "Unauthorized");
|
||||
response.write("wrong token");
|
||||
return;
|
||||
}
|
||||
response.setStatusLine("1.1", 200, "OK");
|
||||
response.write(JSON.stringify({ok: true}));
|
||||
}
|
||||
};
|
||||
let rlserver = new Server(handlers);
|
||||
rlserver.start();
|
||||
let authServer = OAuthTokenServer();
|
||||
authServer.start();
|
||||
try {
|
||||
Services.prefs.setCharPref("readinglist.server", rlserver.host + "/v1");
|
||||
Services.prefs.setCharPref("identity.fxaccounts.remote.oauth.uri", authServer.host + "/v1");
|
||||
|
||||
let fxa = yield createMockFxA();
|
||||
let sc = new ServerClient(fxa);
|
||||
|
||||
let response = yield sc.request({
|
||||
path: "/batch",
|
||||
method: "post",
|
||||
body: {foo: "bar"},
|
||||
});
|
||||
equal(response.status, 200, "got the 200 we expected");
|
||||
equal(authServer.numTokenFetches, 2, "took 2 tokens to get the 200")
|
||||
deepEqual(response.body, {ok: true});
|
||||
} finally {
|
||||
yield authServer.stop();
|
||||
yield rlserver.stop();
|
||||
}
|
||||
});
|
||||
|
||||
// Check that specified headers are seen by the server, and that server headers
|
||||
// in the response are seen by the client.
|
||||
add_task(function testHeaders() {
|
||||
let handlers = {
|
||||
"/v1/batch": (request, response) => {
|
||||
ok(request.hasHeader("x-foo"), "got our foo header");
|
||||
equal(request.getHeader("x-foo"), "bar", "foo header has the correct value");
|
||||
response.setHeader("Server-Sent-Header", "hello");
|
||||
response.setStatusLine("1.1", 200, "OK");
|
||||
response.write("{}");
|
||||
}
|
||||
};
|
||||
let rlserver = new Server(handlers);
|
||||
rlserver.start();
|
||||
try {
|
||||
Services.prefs.setCharPref("readinglist.server", rlserver.host + "/v1");
|
||||
|
||||
let fxa = yield createMockFxA();
|
||||
let sc = new ServerClient(fxa);
|
||||
sc._getToken = () => Promise.resolve();
|
||||
|
||||
let response = yield sc.request({
|
||||
path: "/batch",
|
||||
method: "post",
|
||||
headers: {"X-Foo": "bar"},
|
||||
body: {foo: "bar"}});
|
||||
equal(response.status, 200, "got the 200 we expected");
|
||||
equal(response.headers["server-sent-header"], "hello", "got the server header");
|
||||
} finally {
|
||||
yield rlserver.stop();
|
||||
}
|
||||
});
|
||||
|
||||
// Check that a "backoff" header causes the correct notification.
|
||||
add_task(function testBackoffHeader() {
|
||||
let handlers = {
|
||||
"/v1/batch": (request, response) => {
|
||||
response.setHeader("Backoff", "123");
|
||||
response.setStatusLine("1.1", 200, "OK");
|
||||
response.write("{}");
|
||||
}
|
||||
};
|
||||
let rlserver = new Server(handlers);
|
||||
rlserver.start();
|
||||
|
||||
let observerPromise = promiseObserver("readinglist:backoff-requested");
|
||||
try {
|
||||
Services.prefs.setCharPref("readinglist.server", rlserver.host + "/v1");
|
||||
|
||||
let fxa = yield createMockFxA();
|
||||
let sc = new ServerClient(fxa);
|
||||
sc._getToken = () => Promise.resolve();
|
||||
|
||||
let response = yield sc.request({
|
||||
path: "/batch",
|
||||
method: "post",
|
||||
headers: {"X-Foo": "bar"},
|
||||
body: {foo: "bar"}});
|
||||
equal(response.status, 200, "got the 200 we expected");
|
||||
let data = yield observerPromise;
|
||||
equal(data, "123", "got the expected header value.")
|
||||
} finally {
|
||||
yield rlserver.stop();
|
||||
}
|
||||
});
|
||||
|
||||
// Check that a "backoff" header causes the correct notification.
|
||||
add_task(function testRetryAfterHeader() {
|
||||
let handlers = {
|
||||
"/v1/batch": (request, response) => {
|
||||
response.setHeader("Retry-After", "456");
|
||||
response.setStatusLine("1.1", 500, "Not OK");
|
||||
response.write("{}");
|
||||
}
|
||||
};
|
||||
let rlserver = new Server(handlers);
|
||||
rlserver.start();
|
||||
|
||||
let observerPromise = promiseObserver("readinglist:backoff-requested");
|
||||
try {
|
||||
Services.prefs.setCharPref("readinglist.server", rlserver.host + "/v1");
|
||||
|
||||
let fxa = yield createMockFxA();
|
||||
let sc = new ServerClient(fxa);
|
||||
sc._getToken = () => Promise.resolve();
|
||||
|
||||
let response = yield sc.request({
|
||||
path: "/batch",
|
||||
method: "post",
|
||||
headers: {"X-Foo": "bar"},
|
||||
body: {foo: "bar"}});
|
||||
equal(response.status, 500, "got the 500 we expected");
|
||||
let data = yield observerPromise;
|
||||
equal(data, "456", "got the expected header value.")
|
||||
} finally {
|
||||
yield rlserver.stop();
|
||||
}
|
||||
});
|
||||
|
||||
// Check that unicode ends up as utf-8 in requests, and vice-versa in responses.
|
||||
// (Note the ServerClient assumes all strings in and out are UCS, and thus have
|
||||
// already been encoded/decoded (ie, it never expects to receive stuff already
|
||||
// utf-8 encoded, and never returns utf-8 encoded responses.)
|
||||
add_task(function testUTF8() {
|
||||
let handlers = {
|
||||
"/v1/hello": (request, response) => {
|
||||
// Get the body as bytes.
|
||||
let sis = Cc["@mozilla.org/scriptableinputstream;1"]
|
||||
.createInstance(Ci.nsIScriptableInputStream);
|
||||
sis.init(request.bodyInputStream);
|
||||
let body = sis.read(sis.available());
|
||||
sis.close();
|
||||
// The client sent "{"copyright: "\xa9"} where \xa9 is the copyright symbol.
|
||||
// It should have been encoded as utf-8 which is \xc2\xa9
|
||||
equal(body, '{"copyright":"\xc2\xa9"}', "server saw utf-8 encoded data");
|
||||
// and just write it back unchanged.
|
||||
response.setStatusLine("1.1", 200, "OK");
|
||||
response.write(body);
|
||||
}
|
||||
};
|
||||
let rlserver = new Server(handlers);
|
||||
rlserver.start();
|
||||
try {
|
||||
Services.prefs.setCharPref("readinglist.server", rlserver.host + "/v1");
|
||||
|
||||
let fxa = yield createMockFxA();
|
||||
let sc = new ServerClient(fxa);
|
||||
sc._getToken = () => Promise.resolve();
|
||||
|
||||
let body = {copyright: "\xa9"}; // see above - \xa9 is the copyright symbol
|
||||
let response = yield sc.request({
|
||||
path: "/hello",
|
||||
method: "post",
|
||||
body: body
|
||||
});
|
||||
equal(response.status, 200, "got the 200 we expected");
|
||||
deepEqual(response.body, body);
|
||||
} finally {
|
||||
yield rlserver.stop();
|
||||
}
|
||||
});
|
|
@ -1,333 +0,0 @@
|
|||
/* Any copyright is dedicated to the Public Domain.
|
||||
* http://creativecommons.org/publicdomain/zero/1.0/ */
|
||||
|
||||
"use strict";
|
||||
|
||||
let gProfildDirFile = do_get_profile();
|
||||
|
||||
Cu.import("resource://gre/modules/Log.jsm");
|
||||
Cu.import("resource://gre/modules/Preferences.jsm");
|
||||
Cu.import("resource://gre/modules/Timer.jsm");
|
||||
Cu.import("resource:///modules/readinglist/Sync.jsm");
|
||||
|
||||
let { localRecordFromServerRecord } =
|
||||
Cu.import("resource:///modules/readinglist/Sync.jsm", {});
|
||||
|
||||
let gList;
|
||||
let gSync;
|
||||
let gClient;
|
||||
let gLocalItems = [];
|
||||
|
||||
function run_test() {
|
||||
run_next_test();
|
||||
}
|
||||
|
||||
add_task(function* prepare() {
|
||||
gSync = Sync;
|
||||
gList = Sync.list;
|
||||
let dbFile = gProfildDirFile.clone();
|
||||
dbFile.append(gSync.list._store.pathRelativeToProfileDir);
|
||||
do_register_cleanup(function* () {
|
||||
// Wait for the list's store to close its connection to the database.
|
||||
yield gList.destroy();
|
||||
if (dbFile.exists()) {
|
||||
dbFile.remove(true);
|
||||
}
|
||||
});
|
||||
|
||||
gClient = new MockClient();
|
||||
gSync._client = gClient;
|
||||
|
||||
let dumpAppender = new Log.DumpAppender();
|
||||
dumpAppender.level = Log.Level.All;
|
||||
let logNames = [
|
||||
"readinglist.sync",
|
||||
];
|
||||
for (let name of logNames) {
|
||||
let log = Log.repository.getLogger(name);
|
||||
log.level = Log.Level.All;
|
||||
log.addAppender(dumpAppender);
|
||||
}
|
||||
});
|
||||
|
||||
add_task(function* uploadNewItems() {
|
||||
// Add some local items.
|
||||
for (let i = 0; i < 3; i++) {
|
||||
let record = {
|
||||
url: `http://example.com/${i}`,
|
||||
title: `title ${i}`,
|
||||
addedBy: "device name",
|
||||
};
|
||||
gLocalItems.push(yield gList.addItem(record));
|
||||
}
|
||||
|
||||
Assert.ok(!("resolvedURL" in gLocalItems[0]._record));
|
||||
yield gSync.start();
|
||||
|
||||
// The syncer should update local items with the items in the server response.
|
||||
// e.g., the item didn't have a resolvedURL before sync, but after sync it
|
||||
// should.
|
||||
Assert.ok("resolvedURL" in gLocalItems[0]._record);
|
||||
|
||||
checkItems(gClient.items, gLocalItems);
|
||||
});
|
||||
|
||||
add_task(function* uploadStatusChanges() {
|
||||
// Change an item's unread from true to false.
|
||||
Assert.ok(gLocalItems[0].unread === true);
|
||||
|
||||
gLocalItems[0].unread = false;
|
||||
yield gList.updateItem(gLocalItems[0]);
|
||||
yield gSync.start();
|
||||
|
||||
Assert.ok(gLocalItems[0].unread === false);
|
||||
checkItems(gClient.items, gLocalItems);
|
||||
});
|
||||
|
||||
add_task(function* downloadChanges() {
|
||||
// Change an item on the server.
|
||||
let newTitle = "downloadChanges new title";
|
||||
let response = yield gClient.request({
|
||||
method: "PATCH",
|
||||
path: "/articles/1",
|
||||
body: {
|
||||
title: newTitle,
|
||||
},
|
||||
});
|
||||
Assert.equal(response.status, 200);
|
||||
|
||||
// Add a new item on the server.
|
||||
let newRecord = {
|
||||
url: "http://example.com/downloadChanges-new-item",
|
||||
title: "downloadChanges 2",
|
||||
added_by: "device name",
|
||||
};
|
||||
response = yield gClient.request({
|
||||
method: "POST",
|
||||
path: "/articles",
|
||||
body: newRecord,
|
||||
});
|
||||
Assert.equal(response.status, 201);
|
||||
|
||||
// Delete an item on the server.
|
||||
response = yield gClient.request({
|
||||
method: "DELETE",
|
||||
path: "/articles/2",
|
||||
});
|
||||
Assert.equal(response.status, 200);
|
||||
|
||||
yield gSync.start();
|
||||
|
||||
// Refresh the list of local items. The changed item should be changed
|
||||
// locally, the deleted item should be deleted locally, and the new item
|
||||
// should appear in the list.
|
||||
gLocalItems = (yield gList.iterator({ sort: "guid" }).
|
||||
items(gLocalItems.length));
|
||||
|
||||
Assert.equal(gLocalItems[1].title, newTitle);
|
||||
Assert.equal(gLocalItems[2].url, newRecord.url);
|
||||
checkItems(gClient.items, gLocalItems);
|
||||
});
|
||||
|
||||
|
||||
function MockClient() {
|
||||
this._items = [];
|
||||
this._nextItemID = 0;
|
||||
this._nextLastModifiedToken = 0;
|
||||
}
|
||||
|
||||
MockClient.prototype = {
|
||||
|
||||
request(req) {
|
||||
let response = this._routeRequest(req);
|
||||
return new Promise(resolve => {
|
||||
// Resolve the promise asyncly, just as if this were a real server, so
|
||||
// that we don't somehow end up depending on sync behavior.
|
||||
setTimeout(() => {
|
||||
resolve(response);
|
||||
}, 0);
|
||||
});
|
||||
},
|
||||
|
||||
get items() {
|
||||
return this._items.slice().sort((item1, item2) => {
|
||||
return item2.id < item1.id;
|
||||
});
|
||||
},
|
||||
|
||||
itemByID(id) {
|
||||
return this._items.find(item => item.id == id);
|
||||
},
|
||||
|
||||
itemByURL(url) {
|
||||
return this._items.find(item => item.url == url);
|
||||
},
|
||||
|
||||
_items: null,
|
||||
_nextItemID: null,
|
||||
_nextLastModifiedToken: null,
|
||||
|
||||
_routeRequest(req) {
|
||||
for (let prop in this) {
|
||||
let match = (new RegExp("^" + prop + "$")).exec(req.path);
|
||||
if (match) {
|
||||
let handler = this[prop];
|
||||
let method = req.method.toLowerCase();
|
||||
if (!(method in handler)) {
|
||||
throw new Error(`Handler ${prop} does not support method ${method}`);
|
||||
}
|
||||
let response = handler[method].call(this, req.body, match);
|
||||
// Make sure the response really is JSON'able (1) as a kind of sanity
|
||||
// check, (2) to convert any non-primitives (e.g., new String()) into
|
||||
// primitives, and (3) because that's what the real server returns.
|
||||
response = JSON.parse(JSON.stringify(response));
|
||||
return response;
|
||||
}
|
||||
}
|
||||
throw new Error(`Unrecognized path: ${req.path}`);
|
||||
},
|
||||
|
||||
// route handlers
|
||||
|
||||
"/articles": {
|
||||
|
||||
get(body) {
|
||||
return new MockResponse(200, {
|
||||
// No URL params supported right now.
|
||||
items: this.items,
|
||||
});
|
||||
},
|
||||
|
||||
post(body) {
|
||||
let existingItem = this.itemByURL(body.url);
|
||||
if (existingItem) {
|
||||
// The real server seems to return a 200 if the items are identical.
|
||||
if (areSameItems(existingItem, body)) {
|
||||
return new MockResponse(200);
|
||||
}
|
||||
// 303 see other
|
||||
return new MockResponse(303, {
|
||||
id: existingItem.id,
|
||||
});
|
||||
}
|
||||
body.id = new String(this._nextItemID++);
|
||||
let defaultProps = {
|
||||
last_modified: this._nextLastModifiedToken,
|
||||
preview: "",
|
||||
resolved_url: body.url,
|
||||
resolved_title: body.title,
|
||||
excerpt: "",
|
||||
archived: 0,
|
||||
deleted: 0,
|
||||
favorite: false,
|
||||
is_article: true,
|
||||
word_count: null,
|
||||
unread: true,
|
||||
added_on: null,
|
||||
stored_on: this._nextLastModifiedToken,
|
||||
marked_read_by: null,
|
||||
marked_read_on: null,
|
||||
read_position: null,
|
||||
};
|
||||
for (let prop in defaultProps) {
|
||||
if (!(prop in body) || body[prop] === null) {
|
||||
body[prop] = defaultProps[prop];
|
||||
}
|
||||
}
|
||||
this._nextLastModifiedToken++;
|
||||
this._items.push(body);
|
||||
// 201 created
|
||||
return new MockResponse(201, body);
|
||||
},
|
||||
},
|
||||
|
||||
"/articles/([^/]+)": {
|
||||
|
||||
get(body, routeMatch) {
|
||||
let id = routeMatch[1];
|
||||
let item = this.itemByID(id);
|
||||
if (!item) {
|
||||
return new MockResponse(404);
|
||||
}
|
||||
return new MockResponse(200, item);
|
||||
},
|
||||
|
||||
patch(body, routeMatch) {
|
||||
let id = routeMatch[1];
|
||||
let item = this.itemByID(id);
|
||||
if (!item) {
|
||||
return new MockResponse(404);
|
||||
}
|
||||
for (let prop in body) {
|
||||
item[prop] = body[prop];
|
||||
}
|
||||
item.last_modified = this._nextLastModifiedToken++;
|
||||
return new MockResponse(200, item);
|
||||
},
|
||||
|
||||
// There's a bug in pre-39's ES strict mode around forbidding the
|
||||
// redefinition of reserved keywords that flags defining `delete` on an
|
||||
// object as a syntax error. This weird syntax works around that.
|
||||
["delete"](body, routeMatch) {
|
||||
let id = routeMatch[1];
|
||||
let item = this.itemByID(id);
|
||||
if (!item) {
|
||||
return new MockResponse(404);
|
||||
}
|
||||
item.deleted = true;
|
||||
return new MockResponse(200);
|
||||
},
|
||||
},
|
||||
|
||||
"/batch": {
|
||||
|
||||
post(body) {
|
||||
let responses = [];
|
||||
let defaults = body.defaults || {};
|
||||
for (let request of body.requests) {
|
||||
for (let prop in defaults) {
|
||||
if (!(prop in request)) {
|
||||
request[prop] = defaults[prop];
|
||||
}
|
||||
}
|
||||
responses.push(this._routeRequest(request));
|
||||
}
|
||||
return new MockResponse(200, {
|
||||
defaults: defaults,
|
||||
responses: responses,
|
||||
});
|
||||
},
|
||||
},
|
||||
};
|
||||
|
||||
function MockResponse(status, body, headers={}) {
|
||||
this.status = status;
|
||||
this.body = body;
|
||||
this.headers = headers;
|
||||
}
|
||||
|
||||
function areSameItems(item1, item2) {
|
||||
for (let prop in item1) {
|
||||
if (!(prop in item2) || item1[prop] != item2[prop]) {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
for (let prop in item2) {
|
||||
if (!(prop in item1) || item1[prop] != item2[prop]) {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
return true;
|
||||
}
|
||||
|
||||
function checkItems(serverRecords, localItems) {
|
||||
serverRecords = serverRecords.map(r => localRecordFromServerRecord(r));
|
||||
serverRecords = serverRecords.filter(r => !r.deleted);
|
||||
Assert.equal(serverRecords.length, localItems.length);
|
||||
for (let i = 0; i < serverRecords.length; i++) {
|
||||
for (let prop in localItems[i]._record) {
|
||||
Assert.ok(prop in serverRecords[i], prop);
|
||||
Assert.equal(serverRecords[i][prop], localItems[i]._record[prop]);
|
||||
}
|
||||
}
|
||||
}
|
|
@ -1,255 +0,0 @@
|
|||
/* Any copyright is dedicated to the Public Domain.
|
||||
http://creativecommons.org/publicdomain/zero/1.0/ */
|
||||
|
||||
XPCOMUtils.defineLazyModuleGetter(this, 'setTimeout',
|
||||
'resource://gre/modules/Timer.jsm');
|
||||
|
||||
// Setup logging prefs before importing the scheduler module.
|
||||
Services.prefs.setCharPref("readinglist.log.appender.dump", "Trace");
|
||||
|
||||
let {createTestableScheduler} = Cu.import("resource:///modules/readinglist/Scheduler.jsm", {});
|
||||
Cu.import("resource://gre/modules/Preferences.jsm");
|
||||
Cu.import("resource://gre/modules/Timer.jsm");
|
||||
|
||||
// Log rotation needs a profile dir.
|
||||
do_get_profile();
|
||||
|
||||
let prefs = new Preferences("readinglist.scheduler.");
|
||||
prefs.set("enabled", true);
|
||||
|
||||
function promiseObserver(topic) {
|
||||
return new Promise(resolve => {
|
||||
let obs = (subject, topic, data) => {
|
||||
Services.obs.removeObserver(obs, topic);
|
||||
resolve(data);
|
||||
}
|
||||
Services.obs.addObserver(obs, topic, false);
|
||||
});
|
||||
}
|
||||
|
||||
function ReadingListMock() {
|
||||
this.listener = null;
|
||||
}
|
||||
|
||||
ReadingListMock.prototype = {
|
||||
addListener(listener) {
|
||||
ok(!this.listener, "mock only expects 1 listener");
|
||||
this.listener = listener;
|
||||
},
|
||||
}
|
||||
|
||||
function createScheduler(options) {
|
||||
// avoid typos in the test and other footguns in the options.
|
||||
let allowedOptions = ["expectedDelay", "expectNewTimer", "syncFunction"];
|
||||
for (let key of Object.keys(options)) {
|
||||
if (allowedOptions.indexOf(key) == -1) {
|
||||
throw new Error("Invalid option " + key);
|
||||
}
|
||||
}
|
||||
let rlMock = new ReadingListMock();
|
||||
let scheduler = createTestableScheduler(rlMock);
|
||||
// make our hooks
|
||||
let syncFunction = options.syncFunction || Promise.resolve;
|
||||
scheduler._engine.start = syncFunction;
|
||||
// we expect _setTimeout to be called *twice* - first is the initial sync,
|
||||
// and there's no need to test the delay used for that. options.expectedDelay
|
||||
// is to check the *subsequent* timer.
|
||||
let numCalls = 0;
|
||||
scheduler._setTimeout = function(delay) {
|
||||
++numCalls;
|
||||
print("Test scheduler _setTimeout call number " + numCalls + " with delay=" + delay);
|
||||
switch (numCalls) {
|
||||
case 1:
|
||||
// this is the first and boring schedule as it initializes - do nothing
|
||||
// other than return a timer that fires immediately.
|
||||
return setTimeout(() => scheduler._doSync(), 0);
|
||||
break;
|
||||
case 2:
|
||||
// This is the one we are interested in, so check things.
|
||||
if (options.expectedDelay) {
|
||||
// a little slop is OK as it takes a few ms to actually set the timer
|
||||
ok(Math.abs(options.expectedDelay * 1000 - delay) < 500, [options.expectedDelay * 1000, delay]);
|
||||
}
|
||||
// and return a timeout that "never" fires
|
||||
return setTimeout(() => scheduler._doSync(), 10000000);
|
||||
break;
|
||||
default:
|
||||
// This is unexpected!
|
||||
ok(false, numCalls);
|
||||
}
|
||||
};
|
||||
// And a callback made once we've determined the next delay. This is always
|
||||
// called even if _setTimeout isn't (due to no timer being created)
|
||||
scheduler._onAutoReschedule = () => {
|
||||
// Most tests expect a new timer, so this is "opt out"
|
||||
let expectNewTimer = options.expectNewTimer === undefined ? true : options.expectNewTimer;
|
||||
ok(expectNewTimer ? scheduler._timer : !scheduler._timer);
|
||||
}
|
||||
// calling .init fires things off...
|
||||
scheduler.init();
|
||||
return scheduler;
|
||||
}
|
||||
|
||||
add_task(function* testSuccess() {
|
||||
// promises which resolve once we've got all the expected notifications.
|
||||
let allNotifications = [
|
||||
promiseObserver("readinglist:sync:start"),
|
||||
promiseObserver("readinglist:sync:finish"),
|
||||
];
|
||||
// New delay should be "as regularly scheduled".
|
||||
prefs.set("schedule", 100);
|
||||
let scheduler = createScheduler({expectedDelay: 100});
|
||||
yield Promise.all(allNotifications);
|
||||
scheduler.finalize();
|
||||
});
|
||||
|
||||
// Test that if we get a reading list notification while we are syncing we
|
||||
// immediately start a new one when it complets.
|
||||
add_task(function* testImmediateResyncWhenChangedDuringSync() {
|
||||
// promises which resolve once we've got all the expected notifications.
|
||||
let allNotifications = [
|
||||
promiseObserver("readinglist:sync:start"),
|
||||
promiseObserver("readinglist:sync:finish"),
|
||||
];
|
||||
prefs.set("schedule", 100);
|
||||
// New delay should be "immediate".
|
||||
let scheduler = createScheduler({
|
||||
expectedDelay: 0,
|
||||
syncFunction: () => {
|
||||
// we are now syncing - pretend the readinglist has an item change
|
||||
scheduler.readingList.listener.onItemAdded();
|
||||
return Promise.resolve();
|
||||
}});
|
||||
yield Promise.all(allNotifications);
|
||||
scheduler.finalize();
|
||||
});
|
||||
|
||||
add_task(function* testOffline() {
|
||||
let scheduler = createScheduler({expectNewTimer: false});
|
||||
Services.io.offline = true;
|
||||
ok(!scheduler._canSync(), "_canSync is false when offline.")
|
||||
ok(!scheduler._timer, "there is no current timer while offline.")
|
||||
Services.io.offline = false;
|
||||
ok(scheduler._canSync(), "_canSync is true when online.")
|
||||
ok(scheduler._timer, "there is a new timer when back online.")
|
||||
scheduler.finalize();
|
||||
});
|
||||
|
||||
add_task(function* testRetryableError() {
|
||||
let allNotifications = [
|
||||
promiseObserver("readinglist:sync:start"),
|
||||
promiseObserver("readinglist:sync:error"),
|
||||
];
|
||||
prefs.set("retry", 10);
|
||||
let scheduler = createScheduler({
|
||||
expectedDelay: 10,
|
||||
syncFunction: () => Promise.reject("transient"),
|
||||
});
|
||||
yield Promise.all(allNotifications);
|
||||
scheduler.finalize();
|
||||
});
|
||||
|
||||
add_task(function* testAuthError() {
|
||||
prefs.set("retry", 10);
|
||||
// We expect an auth error to result in no new timer (as it's waiting for
|
||||
// some indication it can proceed), but with the next delay being a normal
|
||||
// "retry" interval (so when we can proceed it is probably already stale, so
|
||||
// is effectively "immediate")
|
||||
let scheduler = createScheduler({
|
||||
expectedDelay: 10,
|
||||
syncFunction: () => {
|
||||
return Promise.reject(ReadingListScheduler._engine.ERROR_AUTHENTICATION);
|
||||
},
|
||||
expectNewTimer: false
|
||||
});
|
||||
// XXX - TODO - send an observer that "unblocks" us and ensure we actually
|
||||
// do unblock.
|
||||
scheduler.finalize();
|
||||
});
|
||||
|
||||
add_task(function* testBackoff() {
|
||||
let scheduler = createScheduler({expectedDelay: 1000});
|
||||
Services.obs.notifyObservers(null, "readinglist:backoff-requested", 1000);
|
||||
// XXX - this needs a little love as nothing checks createScheduler actually
|
||||
// made the checks we think it does.
|
||||
scheduler.finalize();
|
||||
});
|
||||
|
||||
add_task(function testErrorBackoff() {
|
||||
// This test can't sanely use the "test scheduler" above, so make one more
|
||||
// suited.
|
||||
let rlMock = new ReadingListMock();
|
||||
let scheduler = createTestableScheduler(rlMock);
|
||||
scheduler._setTimeout = function(delay) {
|
||||
// create a timer that fires immediately
|
||||
return setTimeout(() => scheduler._doSync(), 0);
|
||||
}
|
||||
|
||||
// This does all the work...
|
||||
function checkBackoffs(expectedSequences) {
|
||||
let orig_maybeReschedule = scheduler._maybeReschedule;
|
||||
return new Promise(resolve => {
|
||||
let isSuccess = true; // ie, first run will put us in "fail" mode.
|
||||
let expected;
|
||||
function nextSequence() {
|
||||
if (expectedSequences.length == 0) {
|
||||
resolve();
|
||||
return true; // we are done.
|
||||
}
|
||||
// setup the current set of expected results.
|
||||
expected = expectedSequences.shift()
|
||||
// and toggle the success status of the engine.
|
||||
isSuccess = !isSuccess;
|
||||
if (isSuccess) {
|
||||
scheduler._engine.start = Promise.resolve;
|
||||
} else {
|
||||
scheduler._engine.start = () => {
|
||||
return Promise.reject(new Error("oh no"))
|
||||
}
|
||||
}
|
||||
return false; // not done.
|
||||
};
|
||||
// get the first sequence;
|
||||
nextSequence();
|
||||
// and setup the scheduler to check the sequences.
|
||||
scheduler._maybeReschedule = function(nextDelay) {
|
||||
let thisExpected = expected.shift();
|
||||
equal(thisExpected * 1000, nextDelay);
|
||||
if (expected.length == 0) {
|
||||
if (nextSequence()) {
|
||||
// we are done, so do nothing.
|
||||
return;
|
||||
}
|
||||
}
|
||||
// call the original impl to get the next schedule.
|
||||
return orig_maybeReschedule.call(scheduler, nextDelay);
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
prefs.set("schedule", 100);
|
||||
prefs.set("retry", 5);
|
||||
// The sequences of timeouts we expect as the Sync error state changes.
|
||||
let backoffsChecked = checkBackoffs([
|
||||
// first sequence is in failure mode - expect the timeout to double until 'schedule'
|
||||
[5, 10, 20, 40, 80, 100, 100],
|
||||
// Sync just started working - more 'schedule'
|
||||
[100, 100],
|
||||
// Just stopped again - error backoff process restarts.
|
||||
[5, 10],
|
||||
// Another success and we are back to 'schedule'
|
||||
[100, 100],
|
||||
]);
|
||||
|
||||
// fire things off.
|
||||
scheduler.init();
|
||||
|
||||
// and wait for completion.
|
||||
yield backoffsChecked;
|
||||
|
||||
scheduler.finalize();
|
||||
});
|
||||
|
||||
function run_test() {
|
||||
run_next_test();
|
||||
}
|
|
@ -1,9 +0,0 @@
|
|||
[DEFAULT]
|
||||
head = head.js
|
||||
firefox-appdir = browser
|
||||
|
||||
[test_ReadingList.js]
|
||||
[test_ServerClient.js]
|
||||
[test_scheduler.js]
|
||||
[test_SQLiteStore.js]
|
||||
[test_Sync.js]
|
|
@ -379,9 +379,10 @@ this.UITour = {
|
|||
},
|
||||
|
||||
onLocationChange: function(aLocation) {
|
||||
// The ReadingList/ReaderView tour page is expected to run in Reader View,
|
||||
// The ReaderView tour page is expected to run in Reader View,
|
||||
// which disables JavaScript on the page. To get around that, we
|
||||
// automatically start a pre-defined tour on page load.
|
||||
// automatically start a pre-defined tour on page load (for hysterical
|
||||
// raisins the ReaderView tour is known as "readinglist")
|
||||
let originalUrl = ReaderMode.getOriginalUrl(aLocation);
|
||||
if (this._readerViewTriggerRegEx.test(originalUrl)) {
|
||||
this.startSubTour("readinglist");
|
||||
|
|
|
@ -26,6 +26,7 @@ CFLAGS="$CFLAGS -Wno-attributes"
|
|||
CPPFLAGS="$CPPFLAGS -Wno-attributes"
|
||||
CXXFLAGS="$CXXFLAGS -Wno-attributes"
|
||||
|
||||
TOOLTOOL_DIR="$(dirname $topsrcdir)"
|
||||
export PKG_CONFIG_LIBDIR=/usr/lib64/pkgconfig:/usr/share/pkgconfig
|
||||
. $topsrcdir/build/unix/mozconfig.gtk
|
||||
|
||||
|
|
|
@ -7,10 +7,11 @@
|
|||
"unpack": true
|
||||
},
|
||||
{
|
||||
"size": 4079256,
|
||||
"digest": "bb5238558bcf6db2ca395513c8dccaa15dd61b3c375598eb6a685356b0c1a2d9840e3bf81bc00242b872fd798541f53d723777c754412abf0e772b7cc284937c",
|
||||
"size": 11179576,
|
||||
"digest": "91567ce8e2bb8ab0ebc60c31e90731d88a1ea889fb71bcf55c735746a60fa7610b7e040ea3d8f727b6f692ae3ee703d6f3b30cdbd76fdf5617f77d9c38aa20ed",
|
||||
"algorithm": "sha512",
|
||||
"filename": "gtk3.tar.xz",
|
||||
"setup": "setup.sh",
|
||||
"unpack": true
|
||||
},
|
||||
{
|
||||
|
|
|
@ -1,16 +0,0 @@
|
|||
[
|
||||
{
|
||||
"size": 80458572,
|
||||
"digest": "e5101f9dee1e462f6cbd3897ea57eede41d23981825c7b20d91d23ab461875d54d3dfc24999aa58a31e8b01f49fb3140e05ffe5af2957ef1d1afb89fd0dfe1ad",
|
||||
"algorithm": "sha512",
|
||||
"filename": "gcc.tar.xz",
|
||||
"unpack": true
|
||||
},
|
||||
{
|
||||
"size": 167175,
|
||||
"digest": "0b71a936edf5bd70cf274aaa5d7abc8f77fe8e7b5593a208f805cc9436fac646b9c4f0b43c2b10de63ff3da671497d35536077ecbc72dba7f8159a38b580f831",
|
||||
"algorithm": "sha512",
|
||||
"filename": "sccache.tar.bz2",
|
||||
"unpack": true
|
||||
}
|
||||
]
|
|
@ -8,5 +8,13 @@
|
|||
"algorithm": "sha512",
|
||||
"filename": "clang.tar.bz2",
|
||||
"unpack": true
|
||||
},
|
||||
{
|
||||
"size": 12057960,
|
||||
"digest": "6105d6432943141cffb40020dc5ba3a793650bdeb3af9bd5e56d3796c5f03df9962a73e521646cd71fbfb5e266c1e74716ad722fb6055589dfb7d35175bca89e",
|
||||
"algorithm": "sha512",
|
||||
"filename": "gtk3.tar.xz",
|
||||
"setup": "setup.sh",
|
||||
"unpack": true
|
||||
}
|
||||
]
|
||||
|
|
|
@ -7,10 +7,11 @@
|
|||
"unpack": true
|
||||
},
|
||||
{
|
||||
"size": 4431740,
|
||||
"digest": "68fc56b0fb0cdba629b95683d6649ff76b00dccf97af90960c3d7716f6108b2162ffd5ffcd5c3a60a21b28674df688fe4dabc67345e2da35ec5abeae3d48c8e3",
|
||||
"size": 12057960,
|
||||
"digest": "6105d6432943141cffb40020dc5ba3a793650bdeb3af9bd5e56d3796c5f03df9962a73e521646cd71fbfb5e266c1e74716ad722fb6055589dfb7d35175bca89e",
|
||||
"algorithm": "sha512",
|
||||
"filename": "gtk3.tar.xz",
|
||||
"setup": "setup.sh",
|
||||
"unpack": true
|
||||
},
|
||||
{
|
||||
|
|
|
@ -10,10 +10,11 @@
|
|||
"unpack": true
|
||||
},
|
||||
{
|
||||
"size": 4431740,
|
||||
"digest": "68fc56b0fb0cdba629b95683d6649ff76b00dccf97af90960c3d7716f6108b2162ffd5ffcd5c3a60a21b28674df688fe4dabc67345e2da35ec5abeae3d48c8e3",
|
||||
"size": 12057960,
|
||||
"digest": "6105d6432943141cffb40020dc5ba3a793650bdeb3af9bd5e56d3796c5f03df9962a73e521646cd71fbfb5e266c1e74716ad722fb6055589dfb7d35175bca89e",
|
||||
"algorithm": "sha512",
|
||||
"filename": "gtk3.tar.xz",
|
||||
"setup": "setup.sh",
|
||||
"unpack": true
|
||||
}
|
||||
]
|
||||
|
|
|
@ -1,16 +0,0 @@
|
|||
[
|
||||
{
|
||||
"size": 80458572,
|
||||
"digest": "e5101f9dee1e462f6cbd3897ea57eede41d23981825c7b20d91d23ab461875d54d3dfc24999aa58a31e8b01f49fb3140e05ffe5af2957ef1d1afb89fd0dfe1ad",
|
||||
"algorithm": "sha512",
|
||||
"filename": "gcc.tar.xz",
|
||||
"unpack": true
|
||||
},
|
||||
{
|
||||
"size": 167175,
|
||||
"digest": "0b71a936edf5bd70cf274aaa5d7abc8f77fe8e7b5593a208f805cc9436fac646b9c4f0b43c2b10de63ff3da671497d35536077ecbc72dba7f8159a38b580f831",
|
||||
"algorithm": "sha512",
|
||||
"filename": "sccache.tar.bz2",
|
||||
"unpack": true
|
||||
}
|
||||
]
|
|
@ -1667,7 +1667,7 @@ RequestsMenuView.prototype = Heritage.extend(WidgetMethods, {
|
|||
text = L10N.getStr("networkMenu.sizeUnavailable");
|
||||
}
|
||||
else if(aValue === "cached") {
|
||||
text = aValue;
|
||||
text = L10N.getStr("networkMenu.sizeCached");
|
||||
node.classList.add('theme-comment');
|
||||
}
|
||||
else {
|
||||
|
|
|
@ -11,8 +11,6 @@
|
|||
|
||||
<!ENTITY abouthome.pageTitle "&brandFullName; Start Page">
|
||||
|
||||
<!ENTITY abouthome.search.placeholder "Search">
|
||||
|
||||
<!-- LOCALIZATION NOTE (abouthome.defaultSnippet1.v1):
|
||||
text in <a/> will be linked to the Firefox features page on mozilla.com
|
||||
-->
|
||||
|
|
|
@ -864,18 +864,6 @@ you can use these alternative items. Otherwise, their values should be empty. -
|
|||
<!ENTITY emeNotificationsDontAskAgain.label "Don't ask me again">
|
||||
<!ENTITY emeNotificationsDontAskAgain.accesskey "D">
|
||||
|
||||
<!ENTITY readingList.label "Reading List">
|
||||
<!ENTITY readingList.sidebar.commandKey "R">
|
||||
<!ENTITY readingList.showSidebar.label "Show Reading List Sidebar">
|
||||
<!-- Pre-landed string for bug 1124153 -->
|
||||
<!ENTITY readingList.sidebar.showMore.label "Show more…">
|
||||
<!-- Pre-landed string for bug 1133662 -->
|
||||
<!ENTITY readingList.sidebar.emptyText "Add articles to your Reading List to save them for later and find them easily when you need them.">
|
||||
<!ENTITY readingList.sidebar.delete.tooltip "Remove this from your Reading List">
|
||||
<!-- Pre-landed strings for bug 1123519 -->
|
||||
<!ENTITY readingList.sidebar.add.label "Add to Reading List">
|
||||
<!ENTITY readingList.sidebar.add.tooltip "Add this page to your Reading List">
|
||||
|
||||
<!-- LOCALIZATION NOTE (saveToPocketCmd.label, saveLinkToPocketCmd.label, pocketMenuitem.label): Pocket is a brand name -->
|
||||
<!ENTITY saveToPocketCmd.label "Save Page to Pocket">
|
||||
<!ENTITY saveToPocketCmd.accesskey "k">
|
||||
|
|
|
@ -736,82 +736,10 @@ appmenu.updateFailed.description = Background update failed, please download upd
|
|||
appmenu.restartBrowserButton.label = Restart %S
|
||||
appmenu.downloadUpdateButton.label = Download Update
|
||||
|
||||
# LOCALIZATION NOTE : FILE Reading List and Reader View are feature names and therefore typically used as proper nouns.
|
||||
# LOCALIZATION NOTE : FILE Reader View is a feature name and therefore typically used as a proper noun.
|
||||
|
||||
# Pre-landed string for bug 1124153
|
||||
# LOCALIZATION NOTE(readingList.sidebar.showMore.tooltip): %S is the number of items that will be added by clicking this button
|
||||
# Semicolon-separated list of plural forms. See:
|
||||
# http://developer.mozilla.org/en/docs/Localization_and_Plurals
|
||||
readingList.sidebar.showMore.tooltip = Show %S more item;Show %S more items
|
||||
# Pre-landed strings for bug 1131457 / bug 1131461
|
||||
readingList.urlbar.add = Add page to Reading List
|
||||
readingList.urlbar.addDone = Page added to Reading List
|
||||
readingList.urlbar.remove = Remove page from Reading List
|
||||
readingList.urlbar.removeDone = Page removed from Reading List
|
||||
# Pre-landed strings for bug 1133610 & bug 1133611
|
||||
# LOCALIZATION NOTE(readingList.promo.noSync.label): %S a link, using the text from readingList.promo.noSync.link
|
||||
readingList.promo.noSync.label = Access your Reading List on all your devices. %S
|
||||
# LOCALIZATION NOTE(readingList.promo.noSync.link): %S is syncBrandShortName
|
||||
readingList.promo.noSync.link = Get started with %S.
|
||||
# LOCALIZATION NOTE(readingList.promo.hasSync.label): %S is syncBrandShortName
|
||||
readingList.promo.hasSync.label = You can now access your Reading List on all your devices connected by %S.
|
||||
|
||||
# Pre-landed strings for bug 1136570
|
||||
readerView.promo.firstDetectedArticle.title = Read and save articles easily
|
||||
readerView.promo.firstDetectedArticle.body = Click the book to make articles easier to read and use the plus to save them for later.
|
||||
readingList.promo.firstUse.exitTourButton = Close
|
||||
# LOCALIZATION NOTE(readingList.promo.firstUse.tourDoneButton):
|
||||
# » is used as an indication that pressing this button progresses through the tour.
|
||||
readingList.promo.firstUse.tourDoneButton = Start Reading »
|
||||
# LOCALIZATION NOTE(readingList.promo.firstUse.readingList.multipleStepsTitle):
|
||||
# This is used when there are multiple steps in the tour.
|
||||
# %1$S is the current step's title (readingList.promo.firstUse.*.title), %2$S is the current step number of the tour, %3$S is the total number of steps.
|
||||
readingList.promo.firstUse.multipleStepsTitle = %1$S (%2$S/%3$S)
|
||||
readingList.promo.firstUse.readingList.title = Reading List
|
||||
readingList.promo.firstUse.readingList.body = Save articles for later and find them easily when you need them.
|
||||
# LOCALIZATION NOTE(readingList.promo.firstUse.readingList.moveToButton):
|
||||
# » is used as an indication that pressing this button progresses through the tour.
|
||||
readingList.promo.firstUse.readingList.moveToButton = Next: Easy finding »
|
||||
readingList.promo.firstUse.readerView.title = Reader View
|
||||
readingList.promo.firstUse.readerView.body = Remove clutter so you can focus exactly on what you want to read.
|
||||
# LOCALIZATION NOTE(readingList.promo.firstUse.readerView.moveToButton):
|
||||
# » is used as an indication that pressing this button progresses through the tour.
|
||||
readingList.promo.firstUse.readerView.moveToButton = Next: Easy reading »
|
||||
readingList.promo.firstUse.syncNotSignedIn.title = Sync
|
||||
# LOCALIZATION NOTE(readingList.promo.firstUse.syncNotSignedIn.body): %S is brandShortName
|
||||
readingList.promo.firstUse.syncNotSignedIn.body = Sign in to access your Reading List everywhere you use %S.
|
||||
# LOCALIZATION NOTE(readingList.promo.firstUse.syncNotSignedIn.moveToButton):
|
||||
# » is used as an indication that pressing this button progresses through the tour.
|
||||
readingList.promo.firstUse.syncNotSignedIn.moveToButton = Next: Easy access »
|
||||
readingList.promo.firstUse.syncSignedIn.title = Sync
|
||||
# LOCALIZATION NOTE(readingList.promo.firstUse.syncSignedIn.body): %S is brandShortName
|
||||
readingList.promo.firstUse.syncSignedIn.body = Open your Reading List articles everywhere you use %S.
|
||||
# LOCALIZATION NOTE(readingList.promo.firstUse.syncSignedIn.moveToButton):
|
||||
# » is used as an indication that pressing this button progresses through the tour.
|
||||
readingList.promo.firstUse.syncSignedIn.moveToButton = Next: Easy access »
|
||||
|
||||
# Pre-landed strings for bug 1136570
|
||||
# LOCALIZATION NOTE(readingList.prepopulatedArticles.learnMore):
|
||||
# This will show as an item in the Reading List, and will link to a page that explains and shows how the Reading List and Reader View works.
|
||||
# This will be staged at:
|
||||
# https://www.allizom.org/firefox/reading/start/
|
||||
# And eventually available at:
|
||||
# https://www.mozilla.org/firefox/reading/start/
|
||||
# %S is brandShortName
|
||||
readingList.prepopulatedArticles.learnMore = Learn how %S makes reading more pleasant
|
||||
# LOCALIZATION NOTE(readingList.prepopulatedArticles.supportReadingList):
|
||||
# This will show as an item in the Reading List, and will link to a SUMO article describing the Reading List:
|
||||
# https://support.mozilla.org/kb/save-sync-and-read-pages-anywhere-reading-list
|
||||
readingList.prepopulatedArticles.supportReadingList = Save, sync and read pages anywhere with Reading List
|
||||
# LOCALIZATION NOTE(readingList.prepopulatedArticles.supportReaderView):
|
||||
# This will show as an item in the Reading List, and will link to a SUMO article describing the Reader View:
|
||||
# https://support.mozilla.org/kb/enjoy-clutter-free-web-pages-reader-view
|
||||
readingList.prepopulatedArticles.supportReaderView = Enjoy clutter-free Web pages with Reader View
|
||||
# LOCALIZATION NOTE(readingList.prepopulatedArticles.learnMore):
|
||||
# This will show as an item in the Reading List, and will link to a SUMO article describing Sync:
|
||||
# https://support.mozilla.org/kb/how-do-i-set-up-firefox-sync
|
||||
# %S is syncBrandShortName
|
||||
readingList.prepopulatedArticles.supportSync = Access your Reading List anywhere with %S
|
||||
|
||||
# LOCALIZATION NOTE (e10s.offerPopup.mainMessage
|
||||
# e10s.offerPopup.highlight1
|
||||
|
|
|
@ -179,6 +179,11 @@ networkMenu.sizeKB=%S KB
|
|||
# unavailable.
|
||||
networkMenu.sizeUnavailable=—
|
||||
|
||||
# LOCALIZATION NOTE (networkMenu.sizeCached): This is the label displayed
|
||||
# in the network menu specifying the transferred of a request is
|
||||
# cached.
|
||||
networkMenu.sizeCached=cached
|
||||
|
||||
# LOCALIZATION NOTE (networkMenu.totalMS): This is the label displayed
|
||||
# in the network menu specifying the time for a request to finish (in milliseconds).
|
||||
networkMenu.totalMS=→ %S ms
|
||||
|
|
|
@ -26,8 +26,6 @@
|
|||
<!ENTITY syncMy.label "Sync My">
|
||||
<!ENTITY engine.bookmarks.label "Bookmarks">
|
||||
<!ENTITY engine.bookmarks.accesskey "m">
|
||||
<!ENTITY engine.readinglist.label "Reading List">
|
||||
<!ENTITY engine.readinglist.accesskey "L">
|
||||
<!ENTITY engine.tabs.label "Tabs">
|
||||
<!ENTITY engine.tabs.accesskey "T">
|
||||
<!ENTITY engine.history.label "History">
|
||||
|
|
|
@ -15,8 +15,6 @@
|
|||
-->
|
||||
<!ENTITY engine.bookmarks.label "Bookmarks">
|
||||
<!ENTITY engine.bookmarks.accesskey "m">
|
||||
<!ENTITY engine.readinglist.label "Reading List">
|
||||
<!ENTITY engine.readinglist.accesskey "L">
|
||||
<!ENTITY engine.history.label "History">
|
||||
<!ENTITY engine.history.accesskey "r">
|
||||
<!ENTITY engine.tabs.label "Tabs">
|
||||
|
|
|
@ -59,6 +59,15 @@ this.E10SUtils = {
|
|||
mustLoadRemote = chromeReg.mustLoadURLRemotely(url);
|
||||
}
|
||||
|
||||
if (aURL.startsWith("moz-extension:")) {
|
||||
canLoadRemote = false;
|
||||
mustLoadRemote = false;
|
||||
}
|
||||
|
||||
if (aURL.startsWith("view-source:")) {
|
||||
return this.canLoadURIInProcess(aURL.substr("view-source:".length), aProcess);
|
||||
}
|
||||
|
||||
if (mustLoadRemote)
|
||||
return processIsRemote;
|
||||
|
||||
|
|
|
@ -16,7 +16,6 @@ Cu.import("resource://gre/modules/Task.jsm");
|
|||
XPCOMUtils.defineLazyModuleGetter(this, "CustomizableUI", "resource:///modules/CustomizableUI.jsm");
|
||||
XPCOMUtils.defineLazyModuleGetter(this, "PlacesUtils","resource://gre/modules/PlacesUtils.jsm");
|
||||
XPCOMUtils.defineLazyModuleGetter(this, "ReaderMode", "resource://gre/modules/ReaderMode.jsm");
|
||||
XPCOMUtils.defineLazyModuleGetter(this, "ReadingList", "resource:///modules/readinglist/ReadingList.jsm");
|
||||
XPCOMUtils.defineLazyModuleGetter(this, "UITour", "resource:///modules/UITour.jsm");
|
||||
|
||||
const gStringBundle = Services.strings.createBundle("chrome://global/locale/aboutReader.properties");
|
||||
|
@ -48,23 +47,6 @@ let ReaderParent = {
|
|||
|
||||
receiveMessage: function(message) {
|
||||
switch (message.name) {
|
||||
case "Reader:AddToList": {
|
||||
let article = message.data.article;
|
||||
ReadingList.getMetadataFromBrowser(message.target).then(function(metadata) {
|
||||
if (metadata.previews.length > 0) {
|
||||
article.preview = metadata.previews[0];
|
||||
}
|
||||
|
||||
ReadingList.addItem({
|
||||
url: article.url,
|
||||
title: article.title,
|
||||
excerpt: article.excerpt,
|
||||
preview: article.preview
|
||||
});
|
||||
});
|
||||
break;
|
||||
}
|
||||
|
||||
case "Reader:AddToPocket": {
|
||||
let doc = message.target.ownerDocument;
|
||||
let pocketWidget = doc.getElementById("pocket-button");
|
||||
|
@ -114,24 +96,6 @@ let ReaderParent = {
|
|||
}
|
||||
break;
|
||||
}
|
||||
case "Reader:ListStatusRequest":
|
||||
ReadingList.hasItemForURL(message.data.url).then(inList => {
|
||||
let mm = message.target.messageManager
|
||||
// Make sure the target browser is still alive before trying to send data back.
|
||||
if (mm) {
|
||||
mm.sendAsyncMessage("Reader:ListStatusData",
|
||||
{ inReadingList: inList, url: message.data.url });
|
||||
}
|
||||
});
|
||||
break;
|
||||
|
||||
case "Reader:RemoveFromList":
|
||||
// We need to get the "real" item to delete it.
|
||||
ReadingList.itemForURL(message.data.url).then(item => {
|
||||
ReadingList.deleteItem(item)
|
||||
});
|
||||
break;
|
||||
|
||||
case "Reader:Share":
|
||||
// XXX: To implement.
|
||||
break;
|
||||
|
|
|
@ -520,11 +520,6 @@ menuitem:not([type]):not(.menuitem-tooltip):not(.menuitem-iconic-tooltip) {
|
|||
list-style-image: url("chrome://browser/skin/places/unsortedBookmarks.png");
|
||||
}
|
||||
|
||||
#menu_readingList,
|
||||
#BMB_readingList {
|
||||
list-style-image: url("chrome://browser/skin/readinglist/readinglist-icon.svg");
|
||||
}
|
||||
|
||||
#panelMenu_pocket,
|
||||
#menu_pocket,
|
||||
#BMB_pocket {
|
||||
|
@ -1287,8 +1282,6 @@ richlistitem[type~="action"][actiontype="switchtab"] > .ac-url-box > .ac-action-
|
|||
list-style-image: url("chrome://browser/skin/Info.png");
|
||||
}
|
||||
|
||||
%include ../shared/readinglist/readinglist.inc.css
|
||||
|
||||
/* Reader mode button */
|
||||
|
||||
#reader-mode-button {
|
||||
|
@ -1951,3 +1944,7 @@ chatbox {
|
|||
-moz-padding-end: 0 !important;
|
||||
-moz-margin-end: 0 !important;
|
||||
}
|
||||
|
||||
.browser-action-panel > .panel-arrowcontainer > .panel-arrowcontent {
|
||||
padding: 0;
|
||||
}
|
||||
|
|
|
@ -113,9 +113,6 @@ browser.jar:
|
|||
skin/classic/browser/reader-tour.png (../shared/reader/reader-tour.png)
|
||||
skin/classic/browser/reader-tour@2x.png (../shared/reader/reader-tour@2x.png)
|
||||
skin/classic/browser/readerMode.svg (../shared/reader/readerMode.svg)
|
||||
skin/classic/browser/readinglist/icons.svg (../shared/readinglist/icons.svg)
|
||||
skin/classic/browser/readinglist/readinglist-icon.svg (../shared/readinglist/readinglist-icon.svg)
|
||||
* skin/classic/browser/readinglist/sidebar.css (readinglist/sidebar.css)
|
||||
skin/classic/browser/webRTC-shareDevice-16.png (../shared/webrtc/webRTC-shareDevice-16.png)
|
||||
skin/classic/browser/webRTC-shareDevice-16@2x.png (../shared/webrtc/webRTC-shareDevice-16@2x.png)
|
||||
skin/classic/browser/webRTC-shareDevice-64.png (../shared/webrtc/webRTC-shareDevice-64.png)
|
||||
|
|
|
@ -1,41 +0,0 @@
|
|||
/* 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/. */
|
||||
|
||||
%include ../../shared/readinglist/sidebar.inc.css
|
||||
|
||||
html {
|
||||
border: 1px solid ThreeDShadow;
|
||||
background-color: -moz-Field;
|
||||
color: -moz-FieldText;
|
||||
box-sizing: border-box;
|
||||
}
|
||||
|
||||
.item {
|
||||
-moz-padding-end: 0;
|
||||
}
|
||||
|
||||
.item.active {
|
||||
background-color: -moz-cellhighlight;
|
||||
color: -moz-cellhighlighttext;
|
||||
}
|
||||
|
||||
.item-title {
|
||||
margin: 1px 0 0;
|
||||
}
|
||||
|
||||
.item-title, .item-domain {
|
||||
-moz-margin-end: 6px;
|
||||
}
|
||||
|
||||
.remove-button {
|
||||
background-image: -moz-image-rect(url("chrome://global/skin/icons/close.svg"), 0, 16, 16, 0);
|
||||
}
|
||||
|
||||
.remove-button:hover {
|
||||
background-image: -moz-image-rect(url("chrome://global/skin/icons/close.svg"), 0, 32, 16, 16);
|
||||
}
|
||||
|
||||
.remove-button:hover:active {
|
||||
background-image: -moz-image-rect(url("chrome://global/skin/icons/close.svg"), 0, 48, 16, 32);
|
||||
}
|
|
@ -567,11 +567,6 @@ toolbarpaletteitem[place="palette"] > #personal-bookmarks > #bookmarks-toolbar-p
|
|||
}
|
||||
}
|
||||
|
||||
/* #menu_readingList, svg icons don't work in the mac native menubar */
|
||||
#BMB_readingList {
|
||||
list-style-image: url("chrome://browser/skin/readinglist/readinglist-icon.svg");
|
||||
}
|
||||
|
||||
#panelMenu_pocket,
|
||||
#menu_pocket,
|
||||
#BMB_pocket {
|
||||
|
@ -2022,8 +2017,6 @@ richlistitem[type~="action"][actiontype="switchtab"][selected="true"] > .ac-url-
|
|||
}
|
||||
}
|
||||
|
||||
%include ../shared/readinglist/readinglist.inc.css
|
||||
|
||||
/* Reader mode button */
|
||||
|
||||
#reader-mode-button {
|
||||
|
@ -3692,3 +3685,7 @@ window > chatbox {
|
|||
padding-left: 0;
|
||||
padding-right: 0;
|
||||
}
|
||||
|
||||
.browser-action-panel > .panel-arrowcontainer > .panel-arrowcontent {
|
||||
padding: 0;
|
||||
}
|
||||
|
|
|
@ -150,9 +150,6 @@ browser.jar:
|
|||
skin/classic/browser/reader-tour.png (../shared/reader/reader-tour.png)
|
||||
skin/classic/browser/reader-tour@2x.png (../shared/reader/reader-tour@2x.png)
|
||||
skin/classic/browser/readerMode.svg (../shared/reader/readerMode.svg)
|
||||
skin/classic/browser/readinglist/icons.svg (../shared/readinglist/icons.svg)
|
||||
skin/classic/browser/readinglist/readinglist-icon.svg (../shared/readinglist/readinglist-icon.svg)
|
||||
* skin/classic/browser/readinglist/sidebar.css (readinglist/sidebar.css)
|
||||
skin/classic/browser/webRTC-shareDevice-16.png (../shared/webrtc/webRTC-shareDevice-16.png)
|
||||
skin/classic/browser/webRTC-shareDevice-16@2x.png (../shared/webrtc/webRTC-shareDevice-16@2x.png)
|
||||
skin/classic/browser/webRTC-shareDevice-64.png (../shared/webrtc/webRTC-shareDevice-64.png)
|
||||
|
|
|
@ -1,39 +0,0 @@
|
|||
/* 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/. */
|
||||
|
||||
%include ../../shared/readinglist/sidebar.inc.css
|
||||
|
||||
html {
|
||||
border-top: 1px solid #bdbdbd;
|
||||
}
|
||||
|
||||
.item-title {
|
||||
margin: 4px 0 0;
|
||||
}
|
||||
|
||||
.remove-button {
|
||||
background-image: -moz-image-rect(url("chrome://global/skin/icons/close.png"), 0, 16, 16, 0);
|
||||
}
|
||||
|
||||
.remove-button:hover {
|
||||
background-image: -moz-image-rect(url("chrome://global/skin/icons/close.png"), 0, 32, 16, 16);
|
||||
}
|
||||
|
||||
.remove-button:hover:active {
|
||||
background-image: -moz-image-rect(url("chrome://global/skin/icons/close.png"), 0, 48, 16, 32);
|
||||
}
|
||||
|
||||
@media (min-resolution: 2dppx) {
|
||||
.remove-button {
|
||||
background-image: -moz-image-rect(url("chrome://global/skin/icons/close@2x.png"), 0, 32, 32, 0);
|
||||
}
|
||||
|
||||
.remove-button:hover {
|
||||
background-image: -moz-image-rect(url("chrome://global/skin/icons/close@2x.png"), 0, 64, 32, 32);
|
||||
}
|
||||
|
||||
.remove-button:hover:active {
|
||||
background-image: -moz-image-rect(url("chrome://global/skin/icons/close@2x.png"), 0, 96, 32, 64);
|
||||
}
|
||||
}
|
Некоторые файлы не были показаны из-за слишком большого количества измененных файлов Показать больше
Загрузка…
Ссылка в новой задаче