diff options
author | Matt A. Tobin <mattatobin@localhost.localdomain> | 2018-02-02 04:16:08 -0500 |
---|---|---|
committer | Matt A. Tobin <mattatobin@localhost.localdomain> | 2018-02-02 04:16:08 -0500 |
commit | 5f8de423f190bbb79a62f804151bc24824fa32d8 (patch) | |
tree | 10027f336435511475e392454359edea8e25895d /browser/components/syncedtabs | |
parent | 49ee0794b5d912db1f95dce6eb52d781dc210db5 (diff) | |
download | UXP-5f8de423f190bbb79a62f804151bc24824fa32d8.tar UXP-5f8de423f190bbb79a62f804151bc24824fa32d8.tar.gz UXP-5f8de423f190bbb79a62f804151bc24824fa32d8.tar.lz UXP-5f8de423f190bbb79a62f804151bc24824fa32d8.tar.xz UXP-5f8de423f190bbb79a62f804151bc24824fa32d8.zip |
Add m-esr52 at 52.6.0
Diffstat (limited to 'browser/components/syncedtabs')
24 files changed, 2757 insertions, 0 deletions
diff --git a/browser/components/syncedtabs/EventEmitter.jsm b/browser/components/syncedtabs/EventEmitter.jsm new file mode 100644 index 000000000..ec3225f0f --- /dev/null +++ b/browser/components/syncedtabs/EventEmitter.jsm @@ -0,0 +1,45 @@ +/* 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; + +this.EXPORTED_SYMBOLS = [ + "EventEmitter" +]; + +// Simple event emitter abstraction for storage objects to use. +function EventEmitter () { + this._events = new Map(); +} + +EventEmitter.prototype = { + on(event, listener) { + if (this._events.has(event)) { + this._events.get(event).add(listener); + } else { + this._events.set(event, new Set([listener])); + } + }, + off(event, listener) { + if (!this._events.has(event)) { + return; + } + this._events.get(event).delete(listener); + }, + emit(event, ...args) { + if (!this._events.has(event)) { + return; + } + for (let listener of this._events.get(event).values()) { + try { + listener.apply(this, args); + } catch (e) { + Cu.reportError(e); + } + } + }, +}; + diff --git a/browser/components/syncedtabs/SyncedTabsDeckComponent.js b/browser/components/syncedtabs/SyncedTabsDeckComponent.js new file mode 100644 index 000000000..c35277795 --- /dev/null +++ b/browser/components/syncedtabs/SyncedTabsDeckComponent.js @@ -0,0 +1,169 @@ +/* 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:///modules/syncedtabs/SyncedTabsDeckStore.js"); +Cu.import("resource:///modules/syncedtabs/SyncedTabsDeckView.js"); +Cu.import("resource:///modules/syncedtabs/SyncedTabsListStore.js"); +Cu.import("resource:///modules/syncedtabs/TabListComponent.js"); +Cu.import("resource:///modules/syncedtabs/TabListView.js"); +let { getChromeWindow } = Cu.import("resource:///modules/syncedtabs/util.js", {}); + +XPCOMUtils.defineLazyGetter(this, "FxAccountsCommon", function () { + return Components.utils.import("resource://gre/modules/FxAccountsCommon.js", {}); +}); + +let log = Cu.import("resource://gre/modules/Log.jsm", {}) + .Log.repository.getLogger("Sync.RemoteTabs"); + +this.EXPORTED_SYMBOLS = [ + "SyncedTabsDeckComponent" +]; + +/* SyncedTabsDeckComponent + * This component instantiates views and storage objects as well as defines + * behaviors that will be passed down to the views. This helps keep the views + * isolated and easier to test. + */ + +function SyncedTabsDeckComponent({ + window, SyncedTabs, fxAccounts, deckStore, listStore, listComponent, DeckView, getChromeWindowMock, +}) { + this._window = window; + this._SyncedTabs = SyncedTabs; + this._fxAccounts = fxAccounts; + this._DeckView = DeckView || SyncedTabsDeckView; + // used to stub during tests + this._getChromeWindow = getChromeWindowMock || getChromeWindow; + + this._deckStore = deckStore || new SyncedTabsDeckStore(); + this._syncedTabsListStore = listStore || new SyncedTabsListStore(SyncedTabs); + this.tabListComponent = listComponent || new TabListComponent({ + window: this._window, + store: this._syncedTabsListStore, + View: TabListView, + SyncedTabs: SyncedTabs, + clipboardHelper: Cc["@mozilla.org/widget/clipboardhelper;1"] + .getService(Ci.nsIClipboardHelper), + getChromeWindow: this._getChromeWindow, + }); +} + +SyncedTabsDeckComponent.prototype = { + PANELS: { + TABS_CONTAINER: "tabs-container", + TABS_FETCHING: "tabs-fetching", + NOT_AUTHED_INFO: "notAuthedInfo", + SINGLE_DEVICE_INFO: "singleDeviceInfo", + TABS_DISABLED: "tabs-disabled", + }, + + get container() { + return this._deckView ? this._deckView.container : null; + }, + + init() { + Services.obs.addObserver(this, this._SyncedTabs.TOPIC_TABS_CHANGED, false); + Services.obs.addObserver(this, FxAccountsCommon.ONLOGIN_NOTIFICATION, false); + + // Go ahead and trigger sync + this._SyncedTabs.syncTabs() + .catch(Cu.reportError); + + this._deckView = new this._DeckView(this._window, this.tabListComponent, { + onAndroidClick: event => this.openAndroidLink(event), + oniOSClick: event => this.openiOSLink(event), + onSyncPrefClick: event => this.openSyncPrefs(event) + }); + + this._deckStore.on("change", state => this._deckView.render(state)); + // Trigger the initial rendering of the deck view + // Object.values only in nightly + this._deckStore.setPanels(Object.keys(this.PANELS).map(k => this.PANELS[k])); + // Set the initial panel to display + this.updatePanel(); + }, + + uninit() { + Services.obs.removeObserver(this, this._SyncedTabs.TOPIC_TABS_CHANGED); + Services.obs.removeObserver(this, FxAccountsCommon.ONLOGIN_NOTIFICATION); + this._deckView.destroy(); + }, + + observe(subject, topic, data) { + switch (topic) { + case this._SyncedTabs.TOPIC_TABS_CHANGED: + this._syncedTabsListStore.getData(); + this.updatePanel(); + break; + case FxAccountsCommon.ONLOGIN_NOTIFICATION: + this.updatePanel(); + break; + default: + break; + } + }, + + // There's no good way to mock fxAccounts in browser tests where it's already + // been instantiated, so we have this method for stubbing. + _accountStatus() { + return this._fxAccounts.accountStatus(); + }, + + getPanelStatus() { + return this._accountStatus().then(exists => { + if (!exists) { + return this.PANELS.NOT_AUTHED_INFO; + } + if (!this._SyncedTabs.isConfiguredToSyncTabs) { + return this.PANELS.TABS_DISABLED; + } + if (!this._SyncedTabs.hasSyncedThisSession) { + return this.PANELS.TABS_FETCHING; + } + return this._SyncedTabs.getTabClients().then(clients => { + if (clients.length) { + return this.PANELS.TABS_CONTAINER; + } + return this.PANELS.SINGLE_DEVICE_INFO; + }); + }) + .catch(err => { + Cu.reportError(err); + return this.PANELS.NOT_AUTHED_INFO; + }); + }, + + updatePanel() { + // return promise for tests + return this.getPanelStatus() + .then(panelId => this._deckStore.selectPanel(panelId)) + .catch(Cu.reportError); + }, + + openAndroidLink(event) { + let href = Services.prefs.getCharPref("identity.mobilepromo.android") + "synced-tabs-sidebar"; + this._openUrl(href, event); + }, + + openiOSLink(event) { + let href = Services.prefs.getCharPref("identity.mobilepromo.ios") + "synced-tabs-sidebar"; + this._openUrl(href, event); + }, + + _openUrl(url, event) { + this._window.openUILink(url, event); + }, + + openSyncPrefs() { + this._getChromeWindow(this._window).gSyncUI.openSetup(null, "tabs-sidebar"); + } +}; + diff --git a/browser/components/syncedtabs/SyncedTabsDeckStore.js b/browser/components/syncedtabs/SyncedTabsDeckStore.js new file mode 100644 index 000000000..ede6914c8 --- /dev/null +++ b/browser/components/syncedtabs/SyncedTabsDeckStore.js @@ -0,0 +1,60 @@ +/* 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; + +let { EventEmitter } = Cu.import("resource:///modules/syncedtabs/EventEmitter.jsm", {}); + +this.EXPORTED_SYMBOLS = [ + "SyncedTabsDeckStore" +]; + +/** + * SyncedTabsDeckStore + * + * This store keeps track of the deck view state, including the panels and which + * one is selected. The view listens for change events on the store, which are + * triggered whenever the state changes. If it's a small change, the state + * will have `isUpdatable` set to true so the view can skip rerendering the whole + * DOM. + */ +function SyncedTabsDeckStore() { + EventEmitter.call(this); + this._panels = []; +} + +Object.assign(SyncedTabsDeckStore.prototype, EventEmitter.prototype, { + _change(isUpdatable = false) { + let panels = this._panels.map(panel => { + return {id: panel, selected: panel === this._selectedPanel}; + }); + this.emit("change", {panels, isUpdatable: isUpdatable}); + }, + + /** + * Sets the selected panelId and triggers a change event. + * @param {String} panelId - ID of the panel to select. + */ + selectPanel(panelId) { + if (this._panels.indexOf(panelId) === -1 || this._selectedPanel === panelId) { + return; + } + this._selectedPanel = panelId; + this._change(true); + }, + + /** + * Update the set of panels in the deck and trigger a change event. + * @param {Array} panels - an array of IDs for each panel in the deck. + */ + setPanels(panels) { + if (panels === this._panels) { + return; + } + this._panels = panels || []; + this._change(); + } +}); diff --git a/browser/components/syncedtabs/SyncedTabsDeckView.js b/browser/components/syncedtabs/SyncedTabsDeckView.js new file mode 100644 index 000000000..e9efff323 --- /dev/null +++ b/browser/components/syncedtabs/SyncedTabsDeckView.js @@ -0,0 +1,116 @@ +/* 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; + +let { getChromeWindow } = Cu.import("resource:///modules/syncedtabs/util.js", {}); + +let log = Cu.import("resource://gre/modules/Log.jsm", {}) + .Log.repository.getLogger("Sync.RemoteTabs"); + +this.EXPORTED_SYMBOLS = [ + "SyncedTabsDeckView" +]; + +/** + * SyncedTabsDeckView + * + * Instances of SyncedTabsDeckView render DOM nodes from a given state. + * No state is kept internaly and the DOM will completely + * rerender unless the state flags `isUpdatable`, which helps + * make small changes without the overhead of a full rerender. + */ +const SyncedTabsDeckView = function (window, tabListComponent, props) { + this.props = props; + + this._window = window; + this._doc = window.document; + + this._tabListComponent = tabListComponent; + this._deckTemplate = this._doc.getElementById("deck-template"); + this.container = this._doc.createElement("div"); +}; + +SyncedTabsDeckView.prototype = { + render(state) { + if (state.isUpdatable) { + this.update(state); + } else { + this.create(state); + } + }, + + create(state) { + let deck = this._doc.importNode(this._deckTemplate.content, true).firstElementChild; + this._clearChilden(); + + let tabListWrapper = this._doc.createElement("div"); + tabListWrapper.className = "tabs-container sync-state"; + this._tabListComponent.init(); + tabListWrapper.appendChild(this._tabListComponent.container); + deck.appendChild(tabListWrapper); + this.container.appendChild(deck); + + this._generateDevicePromo(); + + this._attachListeners(); + this.update(state); + }, + + _getBrowserBundle() { + return getChromeWindow(this._window).document.getElementById("bundle_browser"); + }, + + _generateDevicePromo() { + let bundle = this._getBrowserBundle(); + let formatArgs = ["android", "ios"].map(os => { + let link = this._doc.createElement("a"); + link.textContent = bundle.getString(`appMenuRemoteTabs.mobilePromo.${os}`); + link.className = `${os}-link text-link`; + link.setAttribute("href", "#"); + return link.outerHTML; + }); + // Put it all together... + let contents = bundle.getFormattedString("appMenuRemoteTabs.mobilePromo.text2", formatArgs); + this.container.querySelector(".device-promo").innerHTML = contents; + }, + + destroy() { + this._tabListComponent.uninit(); + this.container.remove(); + }, + + update(state) { + // Note that we may also want to update elements that are outside of the + // deck, so use the document to find the class names rather than our + // container. + for (let panel of state.panels) { + if (panel.selected) { + Array.prototype.map.call(this._doc.getElementsByClassName(panel.id), + item => item.classList.add("selected")); + } else { + Array.prototype.map.call(this._doc.getElementsByClassName(panel.id), + item => item.classList.remove("selected")); + } + } + }, + + _clearChilden() { + while (this.container.firstChild) { + this.container.removeChild(this.container.firstChild); + } + }, + + _attachListeners() { + this.container.querySelector(".android-link").addEventListener("click", this.props.onAndroidClick); + this.container.querySelector(".ios-link").addEventListener("click", this.props.oniOSClick); + let syncPrefLinks = this.container.querySelectorAll(".sync-prefs"); + for (let link of syncPrefLinks) { + link.addEventListener("click", this.props.onSyncPrefClick); + } + }, +}; + diff --git a/browser/components/syncedtabs/SyncedTabsListStore.js b/browser/components/syncedtabs/SyncedTabsListStore.js new file mode 100644 index 000000000..8f03d9a89 --- /dev/null +++ b/browser/components/syncedtabs/SyncedTabsListStore.js @@ -0,0 +1,235 @@ +/* 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; + +let { EventEmitter } = Cu.import("resource:///modules/syncedtabs/EventEmitter.jsm", {}); + +this.EXPORTED_SYMBOLS = [ + "SyncedTabsListStore" +]; + +/** + * SyncedTabsListStore + * + * Instances of this store encapsulate all of the state associated with a synced tabs list view. + * The state includes the clients, their tabs, the row that is currently selected, + * and the filtered query. + */ +function SyncedTabsListStore(SyncedTabs) { + EventEmitter.call(this); + this._SyncedTabs = SyncedTabs; + this.data = []; + this._closedClients = {}; + this._selectedRow = [-1, -1]; + this.filter = ""; + this.inputFocused = false; +} + +Object.assign(SyncedTabsListStore.prototype, EventEmitter.prototype, { + // This internal method triggers the "change" event that views + // listen for. It denormalizes the state so that it's easier for + // the view to deal with. updateType hints to the view what + // actually needs to be rerendered or just updated, and can be + // empty (to (re)render everything), "searchbox" (to rerender just the tab list), + // or "all" (to skip rendering and just update all attributes of existing nodes). + _change(updateType) { + let selectedParent = this._selectedRow[0]; + let selectedChild = this._selectedRow[1]; + let rowSelected = false; + // clone the data so that consumers can't mutate internal storage + let data = Cu.cloneInto(this.data, {}); + let tabCount = 0; + + data.forEach((client, index) => { + client.closed = !!this._closedClients[client.id]; + + if (rowSelected || selectedParent < 0) { + return; + } + if (this.filter) { + if (selectedParent < tabCount + client.tabs.length) { + client.tabs[selectedParent - tabCount].selected = true; + client.tabs[selectedParent - tabCount].focused = !this.inputFocused; + rowSelected = true; + } else { + tabCount += client.tabs.length; + } + return; + } + if (selectedParent === index && selectedChild === -1) { + client.selected = true; + client.focused = !this.inputFocused; + rowSelected = true; + } else if (selectedParent === index) { + client.tabs[selectedChild].selected = true; + client.tabs[selectedChild].focused = !this.inputFocused; + rowSelected = true; + } + }); + + // If this were React the view would be smart enough + // to not re-render the whole list unless necessary. But it's + // not, so updateType is a hint to the view of what actually + // needs to be rerendered. + this.emit("change", { + clients: data, + canUpdateAll: updateType === "all", + canUpdateInput: updateType === "searchbox", + filter: this.filter, + inputFocused: this.inputFocused + }); + }, + + /** + * Moves the row selection from a child to its parent, + * which occurs when the parent of a selected row closes. + */ + _selectParentRow() { + this._selectedRow[1] = -1; + }, + + _toggleBranch(id, closed) { + this._closedClients[id] = closed; + if (this._closedClients[id]) { + this._selectParentRow(); + } + this._change("all"); + }, + + _isOpen(client) { + return !this._closedClients[client.id]; + }, + + moveSelectionDown() { + let branchRow = this._selectedRow[0]; + let childRow = this._selectedRow[1]; + let branch = this.data[branchRow]; + + if (this.filter) { + this.selectRow(branchRow + 1); + return; + } + + if (branchRow < 0) { + this.selectRow(0, -1); + } else if ((!branch.tabs.length || childRow >= branch.tabs.length - 1 || !this._isOpen(branch)) && branchRow < this.data.length) { + this.selectRow(branchRow + 1, -1); + } else if (childRow < branch.tabs.length) { + this.selectRow(branchRow, childRow + 1); + } + }, + + moveSelectionUp() { + let branchRow = this._selectedRow[0]; + let childRow = this._selectedRow[1]; + + if (this.filter) { + this.selectRow(branchRow - 1); + return; + } + + if (branchRow < 0) { + this.selectRow(0, -1); + } else if (childRow < 0 && branchRow > 0) { + let prevBranch = this.data[branchRow - 1]; + let newChildRow = this._isOpen(prevBranch) ? prevBranch.tabs.length - 1 : -1; + this.selectRow(branchRow - 1, newChildRow); + } else if (childRow >= 0) { + this.selectRow(branchRow, childRow - 1); + } + }, + + // Selects a row and makes sure the selection is within bounds + selectRow(parent, child) { + let maxParentRow = this.filter ? this._tabCount() : this.data.length; + let parentRow = parent; + if (parent <= -1) { + parentRow = 0; + } else if (parent >= maxParentRow) { + return; + } + + let childRow = child; + if (parentRow === -1 || this.filter || typeof child === "undefined" || child < -1) { + childRow = -1; + } else if (child >= this.data[parentRow].tabs.length) { + childRow = this.data[parentRow].tabs.length - 1; + } + + if (this._selectedRow[0] === parentRow && this._selectedRow[1] === childRow) { + return; + } + + this._selectedRow = [parentRow, childRow]; + this.inputFocused = false; + this._change("all"); + }, + + _tabCount() { + return this.data.reduce((prev, curr) => curr.tabs.length + prev, 0); + }, + + toggleBranch(id) { + this._toggleBranch(id, !this._closedClients[id]); + }, + + closeBranch(id) { + this._toggleBranch(id, true); + }, + + openBranch(id) { + this._toggleBranch(id, false); + }, + + focusInput() { + this.inputFocused = true; + // A change type of "all" updates rather than rebuilds, which is what we + // want here - only the selection/focus has changed. + this._change("all"); + }, + + blurInput() { + this.inputFocused = false; + // A change type of "all" updates rather than rebuilds, which is what we + // want here - only the selection/focus has changed. + this._change("all"); + }, + + clearFilter() { + this.filter = ""; + this._selectedRow = [-1, -1]; + return this.getData(); + }, + + // Fetches data from the SyncedTabs module and triggers + // and update + getData(filter) { + let updateType; + let hasFilter = typeof filter !== "undefined"; + if (hasFilter) { + this.filter = filter; + this._selectedRow = [-1, -1]; + + // When a filter is specified we tell the view that only the list + // needs to be rerendered so that it doesn't disrupt the input + // field's focus. + updateType = "searchbox"; + } + + // return promise for tests + return this._SyncedTabs.getTabClients(this.filter) + .then(result => { + if (!hasFilter) { + // Only sort clients and tabs if we're rendering the whole list. + this._SyncedTabs.sortTabClientsByLastUsed(result); + } + this.data = result; + this._change(updateType); + }) + .catch(Cu.reportError); + } +}); diff --git a/browser/components/syncedtabs/TabListComponent.js b/browser/components/syncedtabs/TabListComponent.js new file mode 100644 index 000000000..d3aace8f9 --- /dev/null +++ b/browser/components/syncedtabs/TabListComponent.js @@ -0,0 +1,142 @@ +/* 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"); + +let log = Cu.import("resource://gre/modules/Log.jsm", {}) + .Log.repository.getLogger("Sync.RemoteTabs"); + +XPCOMUtils.defineLazyModuleGetter(this, "BrowserUITelemetry", + "resource:///modules/BrowserUITelemetry.jsm"); +XPCOMUtils.defineLazyModuleGetter(this, "PlacesUIUtils", + "resource:///modules/PlacesUIUtils.jsm"); + +this.EXPORTED_SYMBOLS = [ + "TabListComponent" +]; + +/** + * TabListComponent + * + * The purpose of this component is to compose the view, state, and actions. + * It defines high level actions that act on the state and passes them to the + * view for it to trigger during user interaction. It also subscribes the view + * to state changes so it can rerender. + */ + +function TabListComponent({window, store, View, SyncedTabs, clipboardHelper, + getChromeWindow}) { + this._window = window; + this._store = store; + this._View = View; + this._clipboardHelper = clipboardHelper; + this._getChromeWindow = getChromeWindow; + // used to trigger Sync from context menu + this._SyncedTabs = SyncedTabs; +} + +TabListComponent.prototype = { + get container() { + return this._view.container; + }, + + init() { + log.debug("Initializing TabListComponent"); + + this._view = new this._View(this._window, { + onSelectRow: (...args) => this.onSelectRow(...args), + onOpenTab: (...args) => this.onOpenTab(...args), + onOpenTabs: (...args) => this.onOpenTabs(...args), + onMoveSelectionDown: (...args) => this.onMoveSelectionDown(...args), + onMoveSelectionUp: (...args) => this.onMoveSelectionUp(...args), + onToggleBranch: (...args) => this.onToggleBranch(...args), + onBookmarkTab: (...args) => this.onBookmarkTab(...args), + onCopyTabLocation: (...args) => this.onCopyTabLocation(...args), + onSyncRefresh: (...args) => this.onSyncRefresh(...args), + onFilter: (...args) => this.onFilter(...args), + onClearFilter: (...args) => this.onClearFilter(...args), + onFilterFocus: (...args) => this.onFilterFocus(...args), + onFilterBlur: (...args) => this.onFilterBlur(...args) + }); + + this._store.on("change", state => this._view.render(state)); + this._view.render({clients: []}); + // get what's already available... + this._store.getData(); + this._store.focusInput(); + }, + + uninit() { + this._view.destroy(); + }, + + onFilter(query) { + this._store.getData(query); + }, + + onClearFilter() { + this._store.clearFilter(); + }, + + onFilterFocus() { + this._store.focusInput(); + }, + + onFilterBlur() { + this._store.blurInput(); + }, + + onSelectRow(position) { + this._store.selectRow(position[0], position[1]); + }, + + onMoveSelectionDown() { + this._store.moveSelectionDown(); + }, + + onMoveSelectionUp() { + this._store.moveSelectionUp(); + }, + + onToggleBranch(id) { + this._store.toggleBranch(id); + }, + + onBookmarkTab(uri, title) { + this._window.top.PlacesCommandHook + .bookmarkLink(this._window.top.PlacesUtils.bookmarksMenuFolderId, uri, title) + .catch(Cu.reportError); + }, + + onOpenTab(url, where, params) { + this._window.openUILinkIn(url, where, params); + BrowserUITelemetry.countSyncedTabEvent("open", "sidebar"); + }, + + onOpenTabs(urls, where) { + if (!PlacesUIUtils.confirmOpenInTabs(urls.length, this._window)) { + return; + } + if (where == "window") { + this._window.openDialog(this._window.getBrowserURL(), "_blank", + "chrome,dialog=no,all", urls.join("|")); + } else { + let loadInBackground = where == "tabshifted" ? true : false; + this._getChromeWindow(this._window).gBrowser.loadTabs(urls, loadInBackground, false); + } + BrowserUITelemetry.countSyncedTabEvent("openmultiple", "sidebar"); + }, + + onCopyTabLocation(url) { + this._clipboardHelper.copyString(url); + }, + + onSyncRefresh() { + this._SyncedTabs.syncTabs(true); + } +}; diff --git a/browser/components/syncedtabs/TabListView.js b/browser/components/syncedtabs/TabListView.js new file mode 100644 index 000000000..dab15101b --- /dev/null +++ b/browser/components/syncedtabs/TabListView.js @@ -0,0 +1,568 @@ +/* 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"); + +let { getChromeWindow } = Cu.import("resource:///modules/syncedtabs/util.js", {}); + +let log = Cu.import("resource://gre/modules/Log.jsm", {}) + .Log.repository.getLogger("Sync.RemoteTabs"); + +this.EXPORTED_SYMBOLS = [ + "TabListView" +]; + +function getContextMenu(window) { + return getChromeWindow(window).document.getElementById("SyncedTabsSidebarContext"); +} + +function getTabsFilterContextMenu(window) { + return getChromeWindow(window).document.getElementById("SyncedTabsSidebarTabsFilterContext"); +} + +/* + * TabListView + * + * Given a state, this object will render the corresponding DOM. + * It maintains no state of it's own. It listens for DOM events + * and triggers actions that may cause the state to change and + * ultimately the view to rerender. + */ +function TabListView(window, props) { + this.props = props; + + this._window = window; + this._doc = this._window.document; + + this._tabsContainerTemplate = this._doc.getElementById("tabs-container-template"); + this._clientTemplate = this._doc.getElementById("client-template"); + this._emptyClientTemplate = this._doc.getElementById("empty-client-template"); + this._tabTemplate = this._doc.getElementById("tab-template"); + this.tabsFilter = this._doc.querySelector(".tabsFilter"); + this.clearFilter = this._doc.querySelector(".textbox-search-clear"); + this.searchBox = this._doc.querySelector(".search-box"); + this.searchIcon = this._doc.querySelector(".textbox-search-icon"); + + this.container = this._doc.createElement("div"); + + this._attachFixedListeners(); + + this._setupContextMenu(); +} + +TabListView.prototype = { + render(state) { + // Don't rerender anything; just update attributes, e.g. selection + if (state.canUpdateAll) { + this._update(state); + return; + } + // Rerender the tab list + if (state.canUpdateInput) { + this._updateSearchBox(state); + this._createList(state); + return; + } + // Create the world anew + this._create(state); + }, + + // Create the initial DOM from templates + _create(state) { + let wrapper = this._doc.importNode(this._tabsContainerTemplate.content, true).firstElementChild; + this._clearChilden(); + this.container.appendChild(wrapper); + + this.list = this.container.querySelector(".list"); + + this._createList(state); + this._updateSearchBox(state); + + this._attachListListeners(); + }, + + _createList(state) { + this._clearChilden(this.list); + for (let client of state.clients) { + if (state.filter) { + this._renderFilteredClient(client); + } else { + this._renderClient(client); + } + } + if (this.list.firstChild) { + const firstTab = this.list.firstChild.querySelector(".item.tab:first-child .item-title"); + if (firstTab) { + firstTab.setAttribute("tabindex", 2); + } + } + }, + + destroy() { + this._teardownContextMenu(); + this.container.remove(); + }, + + _update(state) { + this._updateSearchBox(state); + for (let client of state.clients) { + let clientNode = this._doc.getElementById("item-" + client.id); + if (clientNode) { + this._updateClient(client, clientNode); + } + + client.tabs.forEach((tab, index) => { + let tabNode = this._doc.getElementById('tab-' + client.id + '-' + index); + this._updateTab(tab, tabNode, index); + }); + } + }, + + // Client rows are hidden when the list is filtered + _renderFilteredClient(client, filter) { + client.tabs.forEach((tab, index) => { + let node = this._renderTab(client, tab, index); + this.list.appendChild(node); + }); + }, + + _renderClient(client) { + let itemNode = client.tabs.length ? + this._createClient(client) : + this._createEmptyClient(client); + + this._updateClient(client, itemNode); + + let tabsList = itemNode.querySelector(".item-tabs-list"); + client.tabs.forEach((tab, index) => { + let node = this._renderTab(client, tab, index); + tabsList.appendChild(node); + }); + + this.list.appendChild(itemNode); + return itemNode; + }, + + _renderTab(client, tab, index) { + let itemNode = this._createTab(tab); + this._updateTab(tab, itemNode, index); + return itemNode; + }, + + _createClient(item) { + return this._doc.importNode(this._clientTemplate.content, true).firstElementChild; + }, + + _createEmptyClient(item) { + return this._doc.importNode(this._emptyClientTemplate.content, true).firstElementChild; + }, + + _createTab(item) { + return this._doc.importNode(this._tabTemplate.content, true).firstElementChild; + }, + + _clearChilden(node) { + let parent = node || this.container; + while (parent.firstChild) { + parent.removeChild(parent.firstChild); + } + }, + + // These listeners are attached only once, when we initialize the view + _attachFixedListeners() { + this.tabsFilter.addEventListener("input", this.onFilter.bind(this)); + this.tabsFilter.addEventListener("focus", this.onFilterFocus.bind(this)); + this.tabsFilter.addEventListener("blur", this.onFilterBlur.bind(this)); + this.clearFilter.addEventListener("click", this.onClearFilter.bind(this)); + this.searchIcon.addEventListener("click", this.onFilterFocus.bind(this)); + }, + + // These listeners have to be re-created every time since we re-create the list + _attachListListeners() { + this.list.addEventListener("click", this.onClick.bind(this)); + this.list.addEventListener("mouseup", this.onMouseUp.bind(this)); + this.list.addEventListener("keydown", this.onKeyDown.bind(this)); + }, + + _updateSearchBox(state) { + if (state.filter) { + this.searchBox.classList.add("filtered"); + } else { + this.searchBox.classList.remove("filtered"); + } + this.tabsFilter.value = state.filter; + if (state.inputFocused) { + this.searchBox.setAttribute("focused", true); + this.tabsFilter.focus(); + } else { + this.searchBox.removeAttribute("focused"); + } + }, + + /** + * Update the element representing an item, ensuring it's in sync with the + * underlying data. + * @param {client} item - Item to use as a source. + * @param {Element} itemNode - Element to update. + */ + _updateClient(item, itemNode) { + itemNode.setAttribute("id", "item-" + item.id); + let lastSync = new Date(item.lastModified); + let lastSyncTitle = getChromeWindow(this._window).gSyncUI.formatLastSyncDate(lastSync); + itemNode.setAttribute("title", lastSyncTitle); + if (item.closed) { + itemNode.classList.add("closed"); + } else { + itemNode.classList.remove("closed"); + } + if (item.selected) { + itemNode.classList.add("selected"); + } else { + itemNode.classList.remove("selected"); + } + if (item.isMobile) { + itemNode.classList.add("device-image-mobile"); + } else { + itemNode.classList.add("device-image-desktop"); + } + if (item.focused) { + itemNode.focus(); + } + itemNode.dataset.id = item.id; + itemNode.querySelector(".item-title").textContent = item.name; + }, + + /** + * Update the element representing a tab, ensuring it's in sync with the + * underlying data. + * @param {tab} item - Item to use as a source. + * @param {Element} itemNode - Element to update. + */ + _updateTab(item, itemNode, index) { + itemNode.setAttribute("title", `${item.title}\n${item.url}`); + itemNode.setAttribute("id", "tab-" + item.client + '-' + index); + if (item.selected) { + itemNode.classList.add("selected"); + } else { + itemNode.classList.remove("selected"); + } + if (item.focused) { + itemNode.focus(); + } + itemNode.dataset.url = item.url; + + itemNode.querySelector(".item-title").textContent = item.title; + + if (item.icon) { + let icon = itemNode.querySelector(".item-icon-container"); + icon.style.backgroundImage = "url(" + item.icon + ")"; + } + }, + + onMouseUp(event) { + if (event.which == 2) { // Middle click + this.onClick(event); + } + }, + + onClick(event) { + let itemNode = this._findParentItemNode(event.target); + if (!itemNode) { + return; + } + + if (itemNode.classList.contains("tab")) { + let url = itemNode.dataset.url; + if (url) { + this.onOpenSelected(url, event); + } + } + + // Middle click on a client + if (itemNode.classList.contains("client")) { + let where = getChromeWindow(this._window).whereToOpenLink(event); + if (where != "current") { + const tabs = itemNode.querySelector(".item-tabs-list").childNodes; + const urls = [...tabs].map(tab => tab.dataset.url); + this.props.onOpenTabs(urls, where); + } + } + + if (event.target.classList.contains("item-twisty-container") + && event.which != 2) { + this.props.onToggleBranch(itemNode.dataset.id); + return; + } + + let position = this._getSelectionPosition(itemNode); + this.props.onSelectRow(position); + }, + + /** + * Handle a keydown event on the list box. + * @param {Event} event - Triggering event. + */ + onKeyDown(event) { + if (event.keyCode == this._window.KeyEvent.DOM_VK_DOWN) { + event.preventDefault(); + this.props.onMoveSelectionDown(); + } else if (event.keyCode == this._window.KeyEvent.DOM_VK_UP) { + event.preventDefault(); + this.props.onMoveSelectionUp(); + } else if (event.keyCode == this._window.KeyEvent.DOM_VK_RETURN) { + let selectedNode = this.container.querySelector('.item.selected'); + if (selectedNode.dataset.url) { + this.onOpenSelected(selectedNode.dataset.url, event); + } else if (selectedNode) { + this.props.onToggleBranch(selectedNode.dataset.id); + } + } + }, + + onBookmarkTab() { + let item = this._getSelectedTabNode(); + if (item) { + let title = item.querySelector(".item-title").textContent; + this.props.onBookmarkTab(item.dataset.url, title); + } + }, + + onCopyTabLocation() { + let item = this._getSelectedTabNode(); + if (item) { + this.props.onCopyTabLocation(item.dataset.url); + } + }, + + onOpenSelected(url, event) { + let where = getChromeWindow(this._window).whereToOpenLink(event); + this.props.onOpenTab(url, where, {}); + }, + + onOpenSelectedFromContextMenu(event) { + let item = this._getSelectedTabNode(); + if (item) { + let where = event.target.getAttribute("where"); + let params = { + private: event.target.hasAttribute("private"), + }; + this.props.onOpenTab(item.dataset.url, where, params); + } + }, + + onFilter(event) { + let query = event.target.value; + if (query) { + this.props.onFilter(query); + } else { + this.props.onClearFilter(); + } + }, + + onClearFilter() { + this.props.onClearFilter(); + }, + + onFilterFocus() { + this.props.onFilterFocus(); + }, + onFilterBlur() { + this.props.onFilterBlur(); + }, + + _getSelectedTabNode() { + let item = this.container.querySelector('.item.selected'); + if (this._isTab(item) && item.dataset.url) { + return item; + } + return null; + }, + + // Set up the custom context menu + _setupContextMenu() { + Services.els.addSystemEventListener(this._window, "contextmenu", this, false); + for (let getMenu of [getContextMenu, getTabsFilterContextMenu]) { + let menu = getMenu(this._window); + menu.addEventListener("popupshowing", this, true); + menu.addEventListener("command", this, true); + } + }, + + _teardownContextMenu() { + // Tear down context menu + Services.els.removeSystemEventListener(this._window, "contextmenu", this, false); + for (let getMenu of [getContextMenu, getTabsFilterContextMenu]) { + let menu = getMenu(this._window); + menu.removeEventListener("popupshowing", this, true); + menu.removeEventListener("command", this, true); + } + }, + + handleEvent(event) { + switch (event.type) { + case "contextmenu": + this.handleContextMenu(event); + break; + + case "popupshowing": { + if (event.target.getAttribute("id") == "SyncedTabsSidebarTabsFilterContext") { + this.handleTabsFilterContextMenuShown(event); + } + break; + } + + case "command": { + let menu = event.target.closest("menupopup"); + switch (menu.getAttribute("id")) { + case "SyncedTabsSidebarContext": + this.handleContentContextMenuCommand(event); + break; + + case "SyncedTabsSidebarTabsFilterContext": + this.handleTabsFilterContextMenuCommand(event); + break; + } + break; + } + } + }, + + handleTabsFilterContextMenuShown(event) { + let document = event.target.ownerDocument; + let focusedElement = document.commandDispatcher.focusedElement; + if (focusedElement != this.tabsFilter) { + this.tabsFilter.focus(); + } + for (let item of event.target.children) { + if (!item.hasAttribute("cmd")) { + continue; + } + let command = item.getAttribute("cmd"); + let controller = document.commandDispatcher.getControllerForCommand(command); + if (controller.isCommandEnabled(command)) { + item.removeAttribute("disabled"); + } else { + item.setAttribute("disabled", "true"); + } + } + }, + + handleContentContextMenuCommand(event) { + let id = event.target.getAttribute("id"); + switch (id) { + case "syncedTabsOpenSelected": + case "syncedTabsOpenSelectedInTab": + case "syncedTabsOpenSelectedInWindow": + case "syncedTabsOpenSelectedInPrivateWindow": + this.onOpenSelectedFromContextMenu(event); + break; + case "syncedTabsBookmarkSelected": + this.onBookmarkTab(); + break; + case "syncedTabsCopySelected": + this.onCopyTabLocation(); + break; + case "syncedTabsRefresh": + case "syncedTabsRefreshFilter": + this.props.onSyncRefresh(); + break; + } + }, + + handleTabsFilterContextMenuCommand(event) { + let command = event.target.getAttribute("cmd"); + let dispatcher = getChromeWindow(this._window).document.commandDispatcher; + let controller = dispatcher.focusedElement.controllers.getControllerForCommand(command); + controller.doCommand(command); + }, + + handleContextMenu(event) { + let menu; + + if (event.target == this.tabsFilter) { + menu = getTabsFilterContextMenu(this._window); + } else { + let itemNode = this._findParentItemNode(event.target); + if (itemNode) { + let position = this._getSelectionPosition(itemNode); + this.props.onSelectRow(position); + } + menu = getContextMenu(this._window); + this.adjustContextMenu(menu); + } + + menu.openPopupAtScreen(event.screenX, event.screenY, true, event); + }, + + adjustContextMenu(menu) { + let item = this.container.querySelector('.item.selected'); + let showTabOptions = this._isTab(item); + + let el = menu.firstChild; + + while (el) { + if (showTabOptions || el.getAttribute("id") === "syncedTabsRefresh") { + el.hidden = false; + } else { + el.hidden = true; + } + + el = el.nextSibling; + } + }, + + /** + * 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 !== this._doc.documentElement && + !node.classList.contains("item")) { + node = node.parentNode; + } + + if (node !== this.list && node !== this._doc.documentElement) { + return node; + } + + return null; + }, + + _findParentBranchNode(node) { + while (node && !node.classList.contains("list") && node !== this._doc.documentElement && + !node.parentNode.classList.contains("list")) { + node = node.parentNode; + } + + if (node !== this.list && node !== this._doc.documentElement) { + return node; + } + + return null; + }, + + _getSelectionPosition(itemNode) { + let parent = this._findParentBranchNode(itemNode); + let parentPosition = this._indexOfNode(parent.parentNode, parent); + let childPosition = -1; + // if the node is not a client, find its position within the parent + if (parent !== itemNode) { + childPosition = this._indexOfNode(itemNode.parentNode, itemNode); + } + return [parentPosition, childPosition]; + }, + + _indexOfNode(parent, child) { + return Array.prototype.indexOf.call(parent.childNodes, child); + }, + + _isTab(item) { + return item && item.classList.contains("tab"); + } +}; diff --git a/browser/components/syncedtabs/jar.mn b/browser/components/syncedtabs/jar.mn new file mode 100644 index 000000000..ba2b105a1 --- /dev/null +++ b/browser/components/syncedtabs/jar.mn @@ -0,0 +1,7 @@ +# This Source Code Form is subject to the terms of the Mozilla Public +# License, v. 2.0. If a copy of the MPL was not distributed with this +# file, You can obtain one at http://mozilla.org/MPL/2.0/. + +browser.jar: + content/browser/syncedtabs/sidebar.xhtml + content/browser/syncedtabs/sidebar.js diff --git a/browser/components/syncedtabs/moz.build b/browser/components/syncedtabs/moz.build new file mode 100644 index 000000000..93c98e65d --- /dev/null +++ b/browser/components/syncedtabs/moz.build @@ -0,0 +1,24 @@ +# 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'] + +BROWSER_CHROME_MANIFESTS += ['test/browser/browser.ini'] + +XPCSHELL_TESTS_MANIFESTS += ['test/xpcshell/xpcshell.ini'] + +EXTRA_JS_MODULES.syncedtabs += [ + 'EventEmitter.jsm', + 'SyncedTabsDeckComponent.js', + 'SyncedTabsDeckStore.js', + 'SyncedTabsDeckView.js', + 'SyncedTabsListStore.js', + 'TabListComponent.js', + 'TabListView.js', + 'util.js', +] + +with Files('**'): + BUG_COMPONENT = ('Firefox', 'Synced tabs') + diff --git a/browser/components/syncedtabs/sidebar.js b/browser/components/syncedtabs/sidebar.js new file mode 100644 index 000000000..84df95e9d --- /dev/null +++ b/browser/components/syncedtabs/sidebar.js @@ -0,0 +1,30 @@ +/* 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://services-sync/SyncedTabs.jsm"); +Cu.import("resource:///modules/syncedtabs/SyncedTabsDeckComponent.js"); + +XPCOMUtils.defineLazyModuleGetter(this, "fxAccounts", + "resource://gre/modules/FxAccounts.jsm"); + +this.syncedTabsDeckComponent = new SyncedTabsDeckComponent({window, SyncedTabs, fxAccounts}); + +let onLoaded = () => { + syncedTabsDeckComponent.init(); + document.getElementById("template-container").appendChild(syncedTabsDeckComponent.container); +}; + +let onUnloaded = () => { + removeEventListener("DOMContentLoaded", onLoaded); + removeEventListener("unload", onUnloaded); + syncedTabsDeckComponent.uninit(); +}; + +addEventListener("DOMContentLoaded", onLoaded); +addEventListener("unload", onUnloaded); diff --git a/browser/components/syncedtabs/sidebar.xhtml b/browser/components/syncedtabs/sidebar.xhtml new file mode 100644 index 000000000..3efcbea0e --- /dev/null +++ b/browser/components/syncedtabs/sidebar.xhtml @@ -0,0 +1,114 @@ +<?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 PUBLIC "-//W3C//DTD XHTML 1.0 Strict//EN" + "http://www.w3.org/TR/xhtml1/DTD/xhtml1-strict.dtd" [ + <!ENTITY % browserDTD SYSTEM "chrome://browser/locale/browser.dtd"> + %browserDTD; + <!ENTITY % globalDTD + SYSTEM "chrome://global/locale/global.dtd"> + %globalDTD; + <!ENTITY % syncBrandDTD + SYSTEM "chrome://browser/locale/syncBrand.dtd"> + %syncBrandDTD; +]> +<html xmlns="http://www.w3.org/1999/xhtml" + xmlns:xul="http://www.mozilla.org/keymaster/gatekeeper/there.is.only.xul"> + <head> + <script src="chrome://browser/content/syncedtabs/sidebar.js" type="application/javascript;version=1.8"></script> + <script type="application/javascript" src="chrome://browser/content/utilityOverlay.js"/> + + <link rel="stylesheet" type="text/css" media="all" href="chrome://browser/skin/syncedtabs/sidebar.css"/> + <link rel="stylesheet" type="text/css" media="all" href="chrome://global/skin/"/> + <link rel="stylesheet" type="text/css" media="all" href="chrome://global/skin/textbox.css"/> + <link rel="stylesheet" type="text/css" media="all" href="chrome://browser/content/browser.css"/> + <title>&syncedTabs.sidebar.label;</title> + </head> + + <body dir="&locale.dir;" role="application"> + <template id="client-template"> + <div class="item client" role="option" tabindex="-1"> + <div class="item-title-container"> + <div class="item-twisty-container"></div> + <div class="item-icon-container"></div> + <p class="item-title"></p> + </div> + <div class="item-tabs-list"></div> + </div> + </template> + <template id="empty-client-template"> + <div class="item empty client" role="option" tabindex="-1"> + <div class="item-title-container"> + <div class="item-twisty-container"></div> + <div class="item-icon-container"></div> + <p class="item-title"></p> + </div> + <div class="item-tabs-list"> + <div class="item empty" role="option" tabindex="-1"> + <div class="item-title-container"> + <div class="item-icon-container"></div> + <p class="item-title">&syncedTabs.sidebar.notabs.label;</p> + </div> + </div> + </div> + </div> + </template> + <template id="tab-template"> + <div class="item tab" role="option" tabindex="-1"> + <div class="item-title-container"> + <div class="item-icon-container"></div> + <p class="item-title"></p> + </div> + </div> + </template> + + <template id="tabs-container-template"> + <div class="tabs-container"> + <div class="list" role="listbox"></div> + </div> + </template> + + <template id="deck-template"> + <div class="deck"> + <div class="tabs-fetching sync-state"> + <!-- Show intentionally blank panel, see bug 1239845 --> + </div> + <div class="notAuthedInfo sync-state"> + <p>&syncedTabs.sidebar.notsignedin.label;</p> + <p><a href="#" class="sync-prefs text-link">&fxaSignIn.label;</a></p> + </div> + <div class="singleDeviceInfo sync-state"> + <p>&syncedTabs.sidebar.noclients.title;</p> + <p>&syncedTabs.sidebar.noclients.subtitle;</p> + <p class="device-promo" fxAccountsBrand="&syncBrand.fxAccount.label;"></p> + </div> + <div class="tabs-disabled sync-state"> + <p>&syncedTabs.sidebar.tabsnotsyncing.label;</p> + <p><a href="#" class="sync-prefs text-link">&syncedTabs.sidebar.openprefs.label;</a></p> + </div> + </div> + </template> + + <div class="content-container"> + <!-- the non-scrollable header --> + <div class="content-header"> + <div class="sidebar-search-container tabs-container sync-state"> + <div class="search-box compact"> + <div class="textbox-input-box"> + <input type="text" class="tabsFilter textbox-input" tabindex="1"/> + <div class="textbox-search-icons"> + <a class="textbox-search-clear"></a> + <a class="textbox-search-icon"></a> + </div> + </div> + </div> + </div> + </div> + <!-- the scrollable content area where our templates are inserted --> + <div id="template-container" class="content-scrollable" tabindex="-1"> + </div> + </div> + </body> +</html> diff --git a/browser/components/syncedtabs/test/browser/.eslintrc.js b/browser/components/syncedtabs/test/browser/.eslintrc.js new file mode 100644 index 000000000..7c8021192 --- /dev/null +++ b/browser/components/syncedtabs/test/browser/.eslintrc.js @@ -0,0 +1,7 @@ +"use strict"; + +module.exports = { + "extends": [ + "../../../../../testing/mochitest/browser.eslintrc.js" + ] +}; diff --git a/browser/components/syncedtabs/test/browser/browser.ini b/browser/components/syncedtabs/test/browser/browser.ini new file mode 100644 index 000000000..02fa364f1 --- /dev/null +++ b/browser/components/syncedtabs/test/browser/browser.ini @@ -0,0 +1,4 @@ +[DEFAULT] +support-files = head.js + +[browser_sidebar_syncedtabslist.js] diff --git a/browser/components/syncedtabs/test/browser/browser_sidebar_syncedtabslist.js b/browser/components/syncedtabs/test/browser/browser_sidebar_syncedtabslist.js new file mode 100644 index 000000000..afbc00282 --- /dev/null +++ b/browser/components/syncedtabs/test/browser/browser_sidebar_syncedtabslist.js @@ -0,0 +1,410 @@ +"use strict"; + +const FIXTURE = [ + { + "id": "7cqCr77ptzX3", + "type": "client", + "name": "zcarter's Nightly on MacBook-Pro-25", + "isMobile": false, + "tabs": [ + { + "type": "tab", + "title": "Firefox for Android — Mobile Web browser — More ways to customize and protect your privacy — Mozilla", + "url": "https://www.mozilla.org/en-US/firefox/android/?utm_source=firefox-browser&utm_medium=firefox-browser&utm_campaign=synced-tabs-sidebar", + "icon": "chrome://mozapps/skin/places/defaultFavicon.png", + "client": "7cqCr77ptzX3", + "lastUsed": 1452124677 + } + ] + }, + { + "id": "2xU5h-4bkWqA", + "type": "client", + "name": "laptop", + "isMobile": false, + "tabs": [ + { + "type": "tab", + "title": "Firefox for iOS — Mobile Web browser for your iPhone, iPad and iPod touch — Mozilla", + "url": "https://www.mozilla.org/en-US/firefox/ios/?utm_source=firefox-browser&utm_medium=firefox-browser&utm_campaign=synced-tabs-sidebar", + "icon": "moz-anno:favicon:https://www.mozilla.org/media/img/firefox/favicon.dc6635050bf5.ico", + "client": "2xU5h-4bkWqA", + "lastUsed": 1451519425 + }, + { + "type": "tab", + "title": "Firefox Nightly First Run Page", + "url": "https://www.mozilla.org/en-US/firefox/nightly/firstrun/?oldversion=45.0a1", + "icon": "moz-anno:favicon:https://www.mozilla.org/media/img/firefox/favicon-nightly.560395bbb2e1.png", + "client": "2xU5h-4bkWqA", + "lastUsed": 1451519420 + }, + { + // Should appear first for this client. + "type": "tab", + "title": "Mozilla Developer Network", + "url": "https://developer.mozilla.org/en-US/", + "icon": "moz-anno:favicon:https://developer.cdn.mozilla.net/static/img/favicon32.e02854fdcf73.png", + "client": "2xU5h-4bkWqA", + "lastUsed": 1451519725 + } + ] + }, + { + "id": "OL3EJCsdb2JD", + "type": "client", + "name": "desktop", + "isMobile": false, + "tabs": [] + } +]; + +let originalSyncedTabsInternal = null; + +function* testClean() { + let syncedTabsDeckComponent = window.SidebarUI.browser.contentWindow.syncedTabsDeckComponent; + let SyncedTabs = window.SidebarUI.browser.contentWindow.SyncedTabs; + syncedTabsDeckComponent._accountStatus.restore(); + SyncedTabs._internal.getTabClients.restore(); + SyncedTabs._internal = originalSyncedTabsInternal; + + yield new Promise(resolve => { + window.SidebarUI.browser.contentWindow.addEventListener("unload", function listener() { + window.SidebarUI.browser.contentWindow.removeEventListener("unload", listener); + resolve(); + }); + SidebarUI.hide(); + }); +} + +add_task(function* testSyncedTabsSidebarList() { + yield SidebarUI.show('viewTabsSidebar'); + + Assert.equal(SidebarUI.currentID, "viewTabsSidebar", "Sidebar should have SyncedTabs loaded"); + + let syncedTabsDeckComponent = SidebarUI.browser.contentWindow.syncedTabsDeckComponent; + let SyncedTabs = SidebarUI.browser.contentWindow.SyncedTabs; + + Assert.ok(syncedTabsDeckComponent, "component exists"); + + originalSyncedTabsInternal = SyncedTabs._internal; + SyncedTabs._internal = { + isConfiguredToSyncTabs: true, + hasSyncedThisSession: true, + getTabClients() { return Promise.resolve([]) }, + syncTabs() { return Promise.resolve(); }, + }; + + sinon.stub(syncedTabsDeckComponent, "_accountStatus", () => Promise.resolve(true)); + sinon.stub(SyncedTabs._internal, "getTabClients", () => Promise.resolve(Cu.cloneInto(FIXTURE, {}))); + + yield syncedTabsDeckComponent.updatePanel(); + // This is a hacky way of waiting for the view to render. The view renders + // after the following promise (a different instance of which is triggered + // in updatePanel) resolves, so we wait for it here as well + yield syncedTabsDeckComponent.tabListComponent._store.getData(); + + Assert.ok(SyncedTabs._internal.getTabClients.called, "get clients called"); + + let selectedPanel = syncedTabsDeckComponent.container.querySelector(".sync-state.selected"); + + + Assert.ok(selectedPanel.classList.contains("tabs-container"), + "tabs panel is selected"); + + Assert.equal(selectedPanel.querySelectorAll(".tab").length, 4, + "four tabs listed"); + Assert.equal(selectedPanel.querySelectorAll(".client").length, 3, + "three clients listed"); + Assert.equal(selectedPanel.querySelectorAll(".client")[2].querySelectorAll(".empty").length, 1, + "third client is empty"); + + // Verify that the tabs are sorted by last used time. + var expectedTabIndices = [[0], [2, 0, 1]]; + Array.prototype.forEach.call(selectedPanel.querySelectorAll(".client"), (clientNode, i) => { + checkItem(clientNode, FIXTURE[i]); + Array.prototype.forEach.call(clientNode.querySelectorAll(".tab"), (tabNode, j) => { + let tabIndex = expectedTabIndices[i][j]; + checkItem(tabNode, FIXTURE[i].tabs[tabIndex]); + }); + }); + +}); + +add_task(testClean); + +add_task(function* testSyncedTabsSidebarFilteredList() { + yield SidebarUI.show('viewTabsSidebar'); + let syncedTabsDeckComponent = window.SidebarUI.browser.contentWindow.syncedTabsDeckComponent; + let SyncedTabs = window.SidebarUI.browser.contentWindow.SyncedTabs; + + Assert.ok(syncedTabsDeckComponent, "component exists"); + + originalSyncedTabsInternal = SyncedTabs._internal; + SyncedTabs._internal = { + isConfiguredToSyncTabs: true, + hasSyncedThisSession: true, + getTabClients() { return Promise.resolve([]) }, + syncTabs() { return Promise.resolve(); }, + }; + + sinon.stub(syncedTabsDeckComponent, "_accountStatus", () => Promise.resolve(true)); + sinon.stub(SyncedTabs._internal, "getTabClients", () => Promise.resolve(Cu.cloneInto(FIXTURE, {}))); + + yield syncedTabsDeckComponent.updatePanel(); + // This is a hacky way of waiting for the view to render. The view renders + // after the following promise (a different instance of which is triggered + // in updatePanel) resolves, so we wait for it here as well + yield syncedTabsDeckComponent.tabListComponent._store.getData(); + + let filterInput = syncedTabsDeckComponent._window.document.querySelector(".tabsFilter"); + filterInput.value = "filter text"; + filterInput.blur(); + + yield syncedTabsDeckComponent.tabListComponent._store.getData("filter text"); + + let selectedPanel = syncedTabsDeckComponent.container.querySelector(".sync-state.selected"); + Assert.ok(selectedPanel.classList.contains("tabs-container"), + "tabs panel is selected"); + + Assert.equal(selectedPanel.querySelectorAll(".tab").length, 4, + "four tabs listed"); + Assert.equal(selectedPanel.querySelectorAll(".client").length, 0, + "no clients are listed"); + + Assert.equal(filterInput.value, "filter text", + "filter text box has correct value"); + + // Tabs should not be sorted when filter is active. + let FIXTURE_TABS = FIXTURE.reduce((prev, client) => prev.concat(client.tabs), []); + + Array.prototype.forEach.call(selectedPanel.querySelectorAll(".tab"), (tabNode, i) => { + checkItem(tabNode, FIXTURE_TABS[i]); + }); + + // Removing the filter should resort tabs. + FIXTURE_TABS.sort((a, b) => b.lastUsed - a.lastUsed); + yield syncedTabsDeckComponent.tabListComponent._store.getData(); + Array.prototype.forEach.call(selectedPanel.querySelectorAll(".tab"), (tabNode, i) => { + checkItem(tabNode, FIXTURE_TABS[i]); + }); +}); + +add_task(testClean); + +add_task(function* testSyncedTabsSidebarStatus() { + let accountExists = false; + + yield SidebarUI.show('viewTabsSidebar'); + let syncedTabsDeckComponent = window.SidebarUI.browser.contentWindow.syncedTabsDeckComponent; + let SyncedTabs = window.SidebarUI.browser.contentWindow.SyncedTabs; + + originalSyncedTabsInternal = SyncedTabs._internal; + SyncedTabs._internal = { + isConfiguredToSyncTabs: false, + hasSyncedThisSession: false, + getTabClients() {}, + syncTabs() { return Promise.resolve(); }, + }; + + Assert.ok(syncedTabsDeckComponent, "component exists"); + + sinon.spy(syncedTabsDeckComponent, "updatePanel"); + sinon.spy(syncedTabsDeckComponent, "observe"); + + sinon.stub(syncedTabsDeckComponent, "_accountStatus", () => Promise.reject("Test error")); + yield syncedTabsDeckComponent.updatePanel(); + + let selectedPanel = syncedTabsDeckComponent.container.querySelector(".sync-state.selected"); + Assert.ok(selectedPanel.classList.contains("notAuthedInfo"), + "not-authed panel is selected on auth error"); + + syncedTabsDeckComponent._accountStatus.restore(); + sinon.stub(syncedTabsDeckComponent, "_accountStatus", () => Promise.resolve(accountExists)); + yield syncedTabsDeckComponent.updatePanel(); + selectedPanel = syncedTabsDeckComponent.container.querySelector(".sync-state.selected"); + Assert.ok(selectedPanel.classList.contains("notAuthedInfo"), + "not-authed panel is selected"); + + accountExists = true; + yield syncedTabsDeckComponent.updatePanel(); + selectedPanel = syncedTabsDeckComponent.container.querySelector(".sync-state.selected"); + Assert.ok(selectedPanel.classList.contains("tabs-disabled"), + "tabs disabled panel is selected"); + + SyncedTabs._internal.isConfiguredToSyncTabs = true; + yield syncedTabsDeckComponent.updatePanel(); + selectedPanel = syncedTabsDeckComponent.container.querySelector(".sync-state.selected"); + Assert.ok(selectedPanel.classList.contains("tabs-fetching"), + "tabs fetch panel is selected"); + + SyncedTabs._internal.hasSyncedThisSession = true; + sinon.stub(SyncedTabs._internal, "getTabClients", () => Promise.resolve([])); + yield syncedTabsDeckComponent.updatePanel(); + selectedPanel = syncedTabsDeckComponent.container.querySelector(".sync-state.selected"); + Assert.ok(selectedPanel.classList.contains("singleDeviceInfo"), + "tabs fetch panel is selected"); + + SyncedTabs._internal.getTabClients.restore(); + sinon.stub(SyncedTabs._internal, "getTabClients", () => Promise.resolve([{id: "mock"}])); + yield syncedTabsDeckComponent.updatePanel(); + selectedPanel = syncedTabsDeckComponent.container.querySelector(".sync-state.selected"); + Assert.ok(selectedPanel.classList.contains("tabs-container"), + "tabs panel is selected"); +}); + +add_task(testClean); + +add_task(function* testSyncedTabsSidebarContextMenu() { + yield SidebarUI.show('viewTabsSidebar'); + let syncedTabsDeckComponent = window.SidebarUI.browser.contentWindow.syncedTabsDeckComponent; + let SyncedTabs = window.SidebarUI.browser.contentWindow.SyncedTabs; + + Assert.ok(syncedTabsDeckComponent, "component exists"); + + originalSyncedTabsInternal = SyncedTabs._internal; + SyncedTabs._internal = { + isConfiguredToSyncTabs: true, + hasSyncedThisSession: true, + getTabClients() { return Promise.resolve([]) }, + syncTabs() { return Promise.resolve(); }, + }; + + sinon.stub(syncedTabsDeckComponent, "_accountStatus", () => Promise.resolve(true)); + sinon.stub(SyncedTabs._internal, "getTabClients", () => Promise.resolve(Cu.cloneInto(FIXTURE, {}))); + + yield syncedTabsDeckComponent.updatePanel(); + // This is a hacky way of waiting for the view to render. The view renders + // after the following promise (a different instance of which is triggered + // in updatePanel) resolves, so we wait for it here as well + yield syncedTabsDeckComponent.tabListComponent._store.getData(); + + info("Right-clicking the search box should show text-related actions"); + let filterMenuItems = [ + "menuitem[cmd=cmd_undo]", + "menuseparator", + // We don't check whether the commands are enabled due to platform + // differences. On OS X and Windows, "cut" and "copy" are always enabled + // for HTML inputs; on Linux, they're only enabled if text is selected. + "menuitem[cmd=cmd_cut]", + "menuitem[cmd=cmd_copy]", + "menuitem[cmd=cmd_paste]", + "menuitem[cmd=cmd_delete]", + "menuseparator", + "menuitem[cmd=cmd_selectAll]", + "menuseparator", + "menuitem#syncedTabsRefreshFilter", + ]; + yield* testContextMenu(syncedTabsDeckComponent, + "#SyncedTabsSidebarTabsFilterContext", + ".tabsFilter", + filterMenuItems); + + info("Right-clicking a tab should show additional actions"); + let tabMenuItems = [ + ["menuitem#syncedTabsOpenSelected", { hidden: false }], + ["menuitem#syncedTabsOpenSelectedInTab", { hidden: false }], + ["menuitem#syncedTabsOpenSelectedInWindow", { hidden: false }], + ["menuitem#syncedTabsOpenSelectedInPrivateWindow", { hidden: false }], + ["menuseparator", { hidden: false }], + ["menuitem#syncedTabsBookmarkSelected", { hidden: false }], + ["menuitem#syncedTabsCopySelected", { hidden: false }], + ["menuseparator", { hidden: false }], + ["menuitem#syncedTabsRefresh", { hidden: false }], + ]; + yield* testContextMenu(syncedTabsDeckComponent, + "#SyncedTabsSidebarContext", + "#tab-7cqCr77ptzX3-0", + tabMenuItems); + + info("Right-clicking a client shouldn't show any actions"); + let sidebarMenuItems = [ + ["menuitem#syncedTabsOpenSelected", { hidden: true }], + ["menuitem#syncedTabsOpenSelectedInTab", { hidden: true }], + ["menuitem#syncedTabsOpenSelectedInWindow", { hidden: true }], + ["menuitem#syncedTabsOpenSelectedInPrivateWindow", { hidden: true }], + ["menuseparator", { hidden: true }], + ["menuitem#syncedTabsBookmarkSelected", { hidden: true }], + ["menuitem#syncedTabsCopySelected", { hidden: true }], + ["menuseparator", { hidden: true }], + ["menuitem#syncedTabsRefresh", { hidden: false }], + ]; + yield* testContextMenu(syncedTabsDeckComponent, + "#SyncedTabsSidebarContext", + "#item-OL3EJCsdb2JD", + sidebarMenuItems); +}); + +add_task(testClean); + +function checkItem(node, item) { + Assert.ok(node.classList.contains("item"), + "Node should have .item class"); + if (item.client) { + // tab items + Assert.equal(node.querySelector(".item-title").textContent, item.title, + "Node's title element's text should match item title"); + Assert.ok(node.classList.contains("tab"), + "Node should have .tab class"); + Assert.equal(node.dataset.url, item.url, + "Node's URL should match item URL"); + Assert.equal(node.getAttribute("title"), item.title + "\n" + item.url, + "Tab node should have correct title attribute"); + } else { + // client items + Assert.equal(node.querySelector(".item-title").textContent, item.name, + "Node's title element's text should match client name"); + Assert.ok(node.classList.contains("client"), + "Node should have .client class"); + Assert.equal(node.dataset.id, item.id, + "Node's ID should match item ID"); + } +} + +function* testContextMenu(syncedTabsDeckComponent, contextSelector, triggerSelector, menuSelectors) { + let contextMenu = document.querySelector(contextSelector); + let triggerElement = syncedTabsDeckComponent._window.document.querySelector(triggerSelector); + let isClosed = triggerElement.classList.contains("closed"); + + let promisePopupShown = BrowserTestUtils.waitForEvent(contextMenu, "popupshown"); + + let chromeWindow = triggerElement.ownerGlobal.top; + let rect = triggerElement.getBoundingClientRect(); + let contentRect = chromeWindow.SidebarUI.browser.getBoundingClientRect(); + // The offsets in `rect` are relative to the content window, but + // `synthesizeMouseAtPoint` calls `nsIDOMWindowUtils.sendMouseEvent`, + // which interprets the offsets relative to the containing *chrome* window. + // This means we need to account for the width and height of any elements + // outside the `browser` element, like `sidebarheader`. + let offsetX = contentRect.x + rect.x + (rect.width / 2); + let offsetY = contentRect.y + rect.y + (rect.height / 4); + + yield EventUtils.synthesizeMouseAtPoint(offsetX, offsetY, { + type: "contextmenu", + button: 2, + }, chromeWindow); + yield promisePopupShown; + is(triggerElement.classList.contains("closed"), isClosed, + "Showing the context menu shouldn't toggle the tab list"); + checkChildren(contextMenu, menuSelectors); + + let promisePopupHidden = BrowserTestUtils.waitForEvent(contextMenu, "popuphidden"); + contextMenu.hidePopup(); + yield promisePopupHidden; +} + +function checkChildren(node, selectors) { + is(node.children.length, selectors.length, "Menu item count doesn't match"); + for (let index = 0; index < node.children.length; index++) { + let child = node.children[index]; + let [selector, props] = [].concat(selectors[index]); + ok(selector, `Node at ${index} should have selector`); + ok(child.matches(selector), `Node ${ + index} should match ${selector}`); + if (props) { + Object.keys(props).forEach(prop => { + is(child[prop], props[prop], `${prop} value at ${index} should match`); + }); + } + } +} diff --git a/browser/components/syncedtabs/test/browser/head.js b/browser/components/syncedtabs/test/browser/head.js new file mode 100644 index 000000000..40e36123e --- /dev/null +++ b/browser/components/syncedtabs/test/browser/head.js @@ -0,0 +1,19 @@ +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/Promise.jsm"); + + +// Load mocking/stubbing library, sinon +// docs: http://sinonjs.org/docs/ +/* global sinon */ +let loader = Cc["@mozilla.org/moz/jssubscript-loader;1"].getService(Ci.mozIJSSubScriptLoader); +loader.loadSubScript("resource://testing-common/sinon-1.16.1.js"); + +registerCleanupFunction(function*() { + // Cleanup window or the test runner will throw an error + delete window.sinon; + delete window.setImmediate; + delete window.clearImmediate; +}); diff --git a/browser/components/syncedtabs/test/xpcshell/.eslintrc.js b/browser/components/syncedtabs/test/xpcshell/.eslintrc.js new file mode 100644 index 000000000..d35787cd2 --- /dev/null +++ b/browser/components/syncedtabs/test/xpcshell/.eslintrc.js @@ -0,0 +1,7 @@ +"use strict"; + +module.exports = { + "extends": [ + "../../../../../testing/xpcshell/xpcshell.eslintrc.js" + ] +}; diff --git a/browser/components/syncedtabs/test/xpcshell/head.js b/browser/components/syncedtabs/test/xpcshell/head.js new file mode 100644 index 000000000..00055231c --- /dev/null +++ b/browser/components/syncedtabs/test/xpcshell/head.js @@ -0,0 +1,29 @@ +const {classes: Cc, interfaces: Ci, utils: Cu, results: Cr} = Components; + +Cu.import("resource://gre/modules/XPCOMUtils.jsm"); +Cu.import("resource://gre/modules/Services.jsm"); + +XPCOMUtils.defineLazyGetter(this, "FxAccountsCommon", function () { + return Components.utils.import("resource://gre/modules/FxAccountsCommon.js", {}); +}); + +Cu.import("resource://gre/modules/Timer.jsm"); + +do_get_profile(); // fxa needs a profile directory for storage. + +// Create a window polyfill so sinon can load +let window = { + document: {}, + location: {}, + setTimeout: setTimeout, + setInterval: setInterval, + clearTimeout: clearTimeout, + clearinterval: clearInterval +}; +let self = window; + +// Load mocking/stubbing library, sinon +// docs: http://sinonjs.org/docs/ +/* global sinon */ +let loader = Cc["@mozilla.org/moz/jssubscript-loader;1"].getService(Ci.mozIJSSubScriptLoader); +loader.loadSubScript("resource://testing-common/sinon-1.16.1.js"); diff --git a/browser/components/syncedtabs/test/xpcshell/test_EventEmitter.js b/browser/components/syncedtabs/test/xpcshell/test_EventEmitter.js new file mode 100644 index 000000000..bc73ac621 --- /dev/null +++ b/browser/components/syncedtabs/test/xpcshell/test_EventEmitter.js @@ -0,0 +1,35 @@ +"use strict"; + +let { EventEmitter } = Cu.import("resource:///modules/syncedtabs/EventEmitter.jsm", {}); + +add_task(function* testSingleListener() { + let eventEmitter = new EventEmitter(); + let spy = sinon.spy(); + + eventEmitter.on("click", spy); + eventEmitter.emit("click", "foo", "bar"); + Assert.ok(spy.calledOnce); + Assert.ok(spy.calledWith("foo", "bar")); + + eventEmitter.off("click", spy); + eventEmitter.emit("click"); + Assert.ok(spy.calledOnce); +}); + +add_task(function* testMultipleListeners() { + let eventEmitter = new EventEmitter(); + let spy1 = sinon.spy(); + let spy2 = sinon.spy(); + + eventEmitter.on("some_event", spy1); + eventEmitter.on("some_event", spy2); + eventEmitter.emit("some_event"); + Assert.ok(spy1.calledOnce); + Assert.ok(spy2.calledOnce); + + eventEmitter.off("some_event", spy1); + eventEmitter.emit("some_event"); + Assert.ok(spy1.calledOnce); + Assert.ok(spy2.calledTwice); +}); + diff --git a/browser/components/syncedtabs/test/xpcshell/test_SyncedTabsDeckComponent.js b/browser/components/syncedtabs/test/xpcshell/test_SyncedTabsDeckComponent.js new file mode 100644 index 000000000..3d748b33c --- /dev/null +++ b/browser/components/syncedtabs/test/xpcshell/test_SyncedTabsDeckComponent.js @@ -0,0 +1,218 @@ +"use strict"; + +let { SyncedTabs } = Cu.import("resource://services-sync/SyncedTabs.jsm", {}); +let { SyncedTabsDeckComponent } = Cu.import("resource:///modules/syncedtabs/SyncedTabsDeckComponent.js", {}); +let { TabListComponent } = Cu.import("resource:///modules/syncedtabs/TabListComponent.js", {}); +let { SyncedTabsListStore } = Cu.import("resource:///modules/syncedtabs/SyncedTabsListStore.js", {}); +let { SyncedTabsDeckStore } = Cu.import("resource:///modules/syncedtabs/SyncedTabsDeckStore.js", {}); +let { TabListView } = Cu.import("resource:///modules/syncedtabs/TabListView.js", {}); +let { DeckView } = Cu.import("resource:///modules/syncedtabs/SyncedTabsDeckView.js", {}); + + +add_task(function* testInitUninit() { + let deckStore = new SyncedTabsDeckStore(); + let listComponent = {}; + + let ViewMock = sinon.stub(); + let view = {render: sinon.spy(), destroy: sinon.spy(), container: {}}; + ViewMock.returns(view); + + sinon.stub(SyncedTabs, "syncTabs", () => Promise.resolve()); + + sinon.spy(deckStore, "on"); + sinon.stub(deckStore, "setPanels"); + + let component = new SyncedTabsDeckComponent({ + window, + deckStore, + listComponent, + SyncedTabs, + DeckView: ViewMock, + }); + + sinon.stub(component, "updatePanel"); + + component.init(); + + Assert.ok(SyncedTabs.syncTabs.called); + SyncedTabs.syncTabs.restore(); + + Assert.ok(ViewMock.calledWithNew(), "view is instantiated"); + Assert.equal(ViewMock.args[0][0], window); + Assert.equal(ViewMock.args[0][1], listComponent); + Assert.ok(ViewMock.args[0][2].onAndroidClick, + "view is passed onAndroidClick prop"); + Assert.ok(ViewMock.args[0][2].oniOSClick, + "view is passed oniOSClick prop"); + Assert.ok(ViewMock.args[0][2].onSyncPrefClick, + "view is passed onSyncPrefClick prop"); + + Assert.equal(component.container, view.container, + "component returns view's container"); + + Assert.ok(deckStore.on.calledOnce, "listener is added to store"); + Assert.equal(deckStore.on.args[0][0], "change"); + // Object.values only in nightly + let values = Object.keys(component.PANELS).map(k => component.PANELS[k]); + Assert.ok(deckStore.setPanels.calledWith(values), + "panels are set on deck store"); + + Assert.ok(component.updatePanel.called); + + deckStore.emit("change", "mock state"); + Assert.ok(view.render.calledWith("mock state"), + "view.render is called on state change"); + + component.uninit(); + + Assert.ok(view.destroy.calledOnce, "view is destroyed on uninit"); +}); + + +function waitForObserver() { + return new Promise((resolve, reject) => { + Services.obs.addObserver((subject, topic) => { + resolve(); + }, SyncedTabs.TOPIC_TABS_CHANGED, false); + }); +} + +add_task(function* testObserver() { + let deckStore = new SyncedTabsDeckStore(); + let listStore = new SyncedTabsListStore(SyncedTabs); + let listComponent = {}; + + let ViewMock = sinon.stub(); + let view = {render: sinon.spy(), destroy: sinon.spy(), container: {}}; + ViewMock.returns(view); + + sinon.stub(SyncedTabs, "syncTabs", () => Promise.resolve()); + + sinon.spy(deckStore, "on"); + sinon.stub(deckStore, "setPanels"); + + sinon.stub(listStore, "getData"); + + let component = new SyncedTabsDeckComponent({ + window, + deckStore, + listStore, + listComponent, + SyncedTabs, + DeckView: ViewMock, + }); + + sinon.spy(component, "observe"); + sinon.stub(component, "updatePanel"); + + component.init(); + SyncedTabs.syncTabs.restore(); + Assert.ok(component.updatePanel.called, "triggers panel update during init"); + + Services.obs.notifyObservers(null, SyncedTabs.TOPIC_TABS_CHANGED, ""); + + Assert.ok(component.observe.calledWith(null, SyncedTabs.TOPIC_TABS_CHANGED, ""), + "component is notified"); + + Assert.ok(listStore.getData.called, "gets list data"); + Assert.ok(component.updatePanel.calledTwice, "triggers panel update"); + + Services.obs.notifyObservers(null, FxAccountsCommon.ONLOGIN_NOTIFICATION, ""); + + Assert.ok(component.observe.calledWith(null, FxAccountsCommon.ONLOGIN_NOTIFICATION, ""), + "component is notified of login"); + Assert.equal(component.updatePanel.callCount, 3, "triggers panel update again"); +}); + +add_task(function* testPanelStatus() { + let deckStore = new SyncedTabsDeckStore(); + let listStore = new SyncedTabsListStore(); + let listComponent = {}; + let fxAccounts = { + accountStatus() {} + }; + let SyncedTabsMock = { + getTabClients() {} + }; + + sinon.stub(listStore, "getData"); + + + let component = new SyncedTabsDeckComponent({ + fxAccounts, + deckStore, + listComponent, + SyncedTabs: SyncedTabsMock, + }); + + let isAuthed = false; + sinon.stub(fxAccounts, "accountStatus", () => Promise.resolve(isAuthed)); + let result = yield component.getPanelStatus(); + Assert.equal(result, component.PANELS.NOT_AUTHED_INFO); + + isAuthed = true; + + SyncedTabsMock.isConfiguredToSyncTabs = false; + result = yield component.getPanelStatus(); + Assert.equal(result, component.PANELS.TABS_DISABLED); + + SyncedTabsMock.isConfiguredToSyncTabs = true; + + SyncedTabsMock.hasSyncedThisSession = false; + result = yield component.getPanelStatus(); + Assert.equal(result, component.PANELS.TABS_FETCHING); + + SyncedTabsMock.hasSyncedThisSession = true; + + let clients = []; + sinon.stub(SyncedTabsMock, "getTabClients", () => Promise.resolve(clients)); + result = yield component.getPanelStatus(); + Assert.equal(result, component.PANELS.SINGLE_DEVICE_INFO); + + clients = ["mock-client"]; + result = yield component.getPanelStatus(); + Assert.equal(result, component.PANELS.TABS_CONTAINER); + + fxAccounts.accountStatus.restore(); + sinon.stub(fxAccounts, "accountStatus", () => Promise.reject("err")); + result = yield component.getPanelStatus(); + Assert.equal(result, component.PANELS.NOT_AUTHED_INFO); + + sinon.stub(component, "getPanelStatus", () => Promise.resolve("mock-panelId")); + sinon.spy(deckStore, "selectPanel"); + yield component.updatePanel(); + Assert.ok(deckStore.selectPanel.calledWith("mock-panelId")); +}); + +add_task(function* testActions() { + let windowMock = { + openUILink() {}, + }; + let chromeWindowMock = { + gSyncUI: { + openSetup() {} + } + }; + sinon.spy(windowMock, "openUILink"); + sinon.spy(chromeWindowMock.gSyncUI, "openSetup"); + + let getChromeWindowMock = sinon.stub(); + getChromeWindowMock.returns(chromeWindowMock); + + let component = new SyncedTabsDeckComponent({ + window: windowMock, + getChromeWindowMock + }); + + let href = Services.prefs.getCharPref("identity.mobilepromo.android") + "synced-tabs-sidebar"; + component.openAndroidLink("mock-event"); + Assert.ok(windowMock.openUILink.calledWith(href, "mock-event")); + + href = Services.prefs.getCharPref("identity.mobilepromo.ios") + "synced-tabs-sidebar"; + component.openiOSLink("mock-event"); + Assert.ok(windowMock.openUILink.calledWith(href, "mock-event")); + + component.openSyncPrefs(); + Assert.ok(getChromeWindowMock.calledWith(windowMock)); + Assert.ok(chromeWindowMock.gSyncUI.openSetup.called); +}); diff --git a/browser/components/syncedtabs/test/xpcshell/test_SyncedTabsDeckStore.js b/browser/components/syncedtabs/test/xpcshell/test_SyncedTabsDeckStore.js new file mode 100644 index 000000000..69abb4024 --- /dev/null +++ b/browser/components/syncedtabs/test/xpcshell/test_SyncedTabsDeckStore.js @@ -0,0 +1,64 @@ +"use strict"; + +let { SyncedTabsDeckStore } = Cu.import("resource:///modules/syncedtabs/SyncedTabsDeckStore.js", {}); + +add_task(function* testSelectUnkownPanel() { + let deckStore = new SyncedTabsDeckStore(); + let spy = sinon.spy(); + + deckStore.on("change", spy); + deckStore.selectPanel("foo"); + + Assert.ok(!spy.called); +}); + +add_task(function* testSetPanels() { + let deckStore = new SyncedTabsDeckStore(); + let spy = sinon.spy(); + + deckStore.on("change", spy); + deckStore.setPanels(["panel1", "panel2"]); + + Assert.ok(spy.calledWith({ + panels: [ + { id: "panel1", selected: false }, + { id: "panel2", selected: false }, + ], + isUpdatable: false + })); +}); + +add_task(function* testSelectPanel() { + let deckStore = new SyncedTabsDeckStore(); + let spy = sinon.spy(); + + deckStore.setPanels(["panel1", "panel2"]); + + deckStore.on("change", spy); + deckStore.selectPanel("panel2"); + + Assert.ok(spy.calledWith({ + panels: [ + { id: "panel1", selected: false }, + { id: "panel2", selected: true }, + ], + isUpdatable: true + })); + + deckStore.selectPanel("panel2"); + Assert.ok(spy.calledOnce, "doesn't trigger unless panel changes"); +}); + +add_task(function* testSetPanelsSameArray() { + let deckStore = new SyncedTabsDeckStore(); + let spy = sinon.spy(); + deckStore.on("change", spy); + + let panels = ["panel1", "panel2"]; + + deckStore.setPanels(panels); + deckStore.setPanels(panels); + + Assert.ok(spy.calledOnce, "doesn't trigger unless set of panels changes"); +}); + diff --git a/browser/components/syncedtabs/test/xpcshell/test_SyncedTabsListStore.js b/browser/components/syncedtabs/test/xpcshell/test_SyncedTabsListStore.js new file mode 100644 index 000000000..51580235f --- /dev/null +++ b/browser/components/syncedtabs/test/xpcshell/test_SyncedTabsListStore.js @@ -0,0 +1,266 @@ +"use strict"; + +let { SyncedTabs } = Cu.import("resource://services-sync/SyncedTabs.jsm", {}); +let { SyncedTabsListStore } = Cu.import("resource:///modules/syncedtabs/SyncedTabsListStore.js", {}); + +const FIXTURE = [ + { + "id": "2xU5h-4bkWqA", + "type": "client", + "name": "laptop", + "isMobile": false, + "tabs": [ + { + "type": "tab", + "title": "Firefox for iOS — Mobile Web browser for your iPhone, iPad and iPod touch — Mozilla", + "url": "https://www.mozilla.org/en-US/firefox/ios/?utm_source=firefox-browser&utm_medium=firefox-browser&utm_campaign=synced-tabs-sidebar", + "icon": "moz-anno:favicon:https://www.mozilla.org/media/img/firefox/favicon.dc6635050bf5.ico", + "client": "2xU5h-4bkWqA", + "lastUsed": 1451519425 + }, + { + "type": "tab", + "title": "Firefox Nightly First Run Page", + "url": "https://www.mozilla.org/en-US/firefox/nightly/firstrun/?oldversion=45.0a1", + "icon": "moz-anno:favicon:https://www.mozilla.org/media/img/firefox/favicon-nightly.560395bbb2e1.png", + "client": "2xU5h-4bkWqA", + "lastUsed": 1451519420 + } + ] + }, + { + "id": "OL3EJCsdb2JD", + "type": "client", + "name": "desktop", + "isMobile": false, + "tabs": [] + } +]; + +add_task(function* testGetDataEmpty() { + let store = new SyncedTabsListStore(SyncedTabs); + let spy = sinon.spy(); + + sinon.stub(SyncedTabs, "getTabClients", () => { + return Promise.resolve([]); + }); + store.on("change", spy); + + yield store.getData(); + + Assert.ok(SyncedTabs.getTabClients.calledWith("")); + Assert.ok(spy.calledWith({ + clients: [], + canUpdateAll: false, + canUpdateInput: false, + filter: "", + inputFocused: false + })); + + yield store.getData("filter"); + + Assert.ok(SyncedTabs.getTabClients.calledWith("filter")); + Assert.ok(spy.calledWith({ + clients: [], + canUpdateAll: false, + canUpdateInput: true, + filter: "filter", + inputFocused: false + })); + + SyncedTabs.getTabClients.restore(); +}); + +add_task(function* testRowSelectionWithoutFilter() { + let store = new SyncedTabsListStore(SyncedTabs); + let spy = sinon.spy(); + + sinon.stub(SyncedTabs, "getTabClients", () => { + return Promise.resolve(FIXTURE); + }); + + yield store.getData(); + SyncedTabs.getTabClients.restore(); + + store.on("change", spy); + + store.selectRow(0, -1); + Assert.ok(spy.args[0][0].canUpdateAll, "can update the whole view"); + Assert.ok(spy.args[0][0].clients[0].selected, "first client is selected"); + + store.moveSelectionUp(); + Assert.ok(spy.calledOnce, + "can't move up past first client, no change triggered"); + + store.selectRow(0, 0); + Assert.ok(spy.args[1][0].clients[0].tabs[0].selected, + "first tab of first client is selected"); + + store.selectRow(0, 0); + Assert.ok(spy.calledTwice, "selecting same row doesn't trigger change"); + + store.selectRow(0, 1); + Assert.ok(spy.args[2][0].clients[0].tabs[1].selected, + "second tab of first client is selected"); + + store.selectRow(1); + Assert.ok(spy.args[3][0].clients[1].selected, "second client is selected"); + + store.moveSelectionDown(); + Assert.equal(spy.callCount, 4, + "can't move selection down past last client, no change triggered"); + + store.moveSelectionUp(); + Assert.equal(spy.callCount, 5, + "changed"); + Assert.ok(spy.args[4][0].clients[0].tabs[FIXTURE[0].tabs.length - 1].selected, + "move selection up from client selects last tab of previous client"); + + store.moveSelectionUp(); + Assert.ok(spy.args[5][0].clients[0].tabs[FIXTURE[0].tabs.length - 2].selected, + "move selection up from tab selects previous tab of client"); +}); + + +add_task(function* testToggleBranches() { + let store = new SyncedTabsListStore(SyncedTabs); + let spy = sinon.spy(); + + sinon.stub(SyncedTabs, "getTabClients", () => { + return Promise.resolve(FIXTURE); + }); + + yield store.getData(); + SyncedTabs.getTabClients.restore(); + + store.selectRow(0); + store.on("change", spy); + + let clientId = FIXTURE[0].id; + store.closeBranch(clientId); + Assert.ok(spy.args[0][0].clients[0].closed, "first client is closed"); + + store.openBranch(clientId); + Assert.ok(!spy.args[1][0].clients[0].closed, "first client is open"); + + store.toggleBranch(clientId); + Assert.ok(spy.args[2][0].clients[0].closed, "first client is toggled closed"); + + store.moveSelectionDown(); + Assert.ok(spy.args[3][0].clients[1].selected, + "selection skips tabs if client is closed"); + + store.moveSelectionUp(); + Assert.ok(spy.args[4][0].clients[0].selected, + "selection skips tabs if client is closed"); +}); + + +add_task(function* testRowSelectionWithFilter() { + let store = new SyncedTabsListStore(SyncedTabs); + let spy = sinon.spy(); + + sinon.stub(SyncedTabs, "getTabClients", () => { + return Promise.resolve(FIXTURE); + }); + + yield store.getData("filter"); + SyncedTabs.getTabClients.restore(); + + store.on("change", spy); + + store.selectRow(0); + Assert.ok(spy.args[0][0].clients[0].tabs[0].selected, "first tab is selected"); + + store.moveSelectionUp(); + Assert.ok(spy.calledOnce, + "can't move up past first tab, no change triggered"); + + store.moveSelectionDown(); + Assert.ok(spy.args[1][0].clients[0].tabs[1].selected, + "selection skips tabs if client is closed"); + + store.moveSelectionDown(); + Assert.equal(spy.callCount, 2, + "can't move selection down past last tab, no change triggered"); + + store.selectRow(1); + Assert.equal(spy.callCount, 2, + "doesn't trigger change if same row selected"); + +}); + + +add_task(function* testFilterAndClearFilter() { + let store = new SyncedTabsListStore(SyncedTabs); + let spy = sinon.spy(); + + sinon.stub(SyncedTabs, "getTabClients", () => { + return Promise.resolve(FIXTURE); + }); + store.on("change", spy); + + yield store.getData("filter"); + + Assert.ok(SyncedTabs.getTabClients.calledWith("filter")); + Assert.ok(!spy.args[0][0].canUpdateAll, "can't update all"); + Assert.ok(spy.args[0][0].canUpdateInput, "can update just input"); + + store.selectRow(0); + + Assert.equal(spy.args[1][0].filter, "filter"); + Assert.ok(spy.args[1][0].clients[0].tabs[0].selected, + "tab is selected"); + + yield store.clearFilter(); + + Assert.ok(SyncedTabs.getTabClients.calledWith("")); + Assert.ok(!spy.args[2][0].canUpdateAll, "can't update all"); + Assert.ok(!spy.args[2][0].canUpdateInput, "can't just update input"); + + Assert.equal(spy.args[2][0].filter, ""); + Assert.ok(!spy.args[2][0].clients[0].tabs[0].selected, + "tab is no longer selected"); + + SyncedTabs.getTabClients.restore(); +}); + +add_task(function* testFocusBlurInput() { + let store = new SyncedTabsListStore(SyncedTabs); + let spy = sinon.spy(); + + sinon.stub(SyncedTabs, "getTabClients", () => { + return Promise.resolve(FIXTURE); + }); + store.on("change", spy); + + yield store.getData(); + SyncedTabs.getTabClients.restore(); + + Assert.ok(!spy.args[0][0].canUpdateAll, "must rerender all"); + + store.selectRow(0); + Assert.ok(!spy.args[1][0].inputFocused, + "input is not focused"); + Assert.ok(spy.args[1][0].clients[0].selected, + "client is selected"); + Assert.ok(spy.args[1][0].clients[0].focused, + "client is focused"); + + store.focusInput(); + Assert.ok(spy.args[2][0].inputFocused, + "input is focused"); + Assert.ok(spy.args[2][0].clients[0].selected, + "client is still selected"); + Assert.ok(!spy.args[2][0].clients[0].focused, + "client is no longer focused"); + + store.blurInput(); + Assert.ok(!spy.args[3][0].inputFocused, + "input is not focused"); + Assert.ok(spy.args[3][0].clients[0].selected, + "client is selected"); + Assert.ok(spy.args[3][0].clients[0].focused, + "client is focused"); +}); + diff --git a/browser/components/syncedtabs/test/xpcshell/test_TabListComponent.js b/browser/components/syncedtabs/test/xpcshell/test_TabListComponent.js new file mode 100644 index 000000000..0b0665a1b --- /dev/null +++ b/browser/components/syncedtabs/test/xpcshell/test_TabListComponent.js @@ -0,0 +1,155 @@ +"use strict"; + +let { SyncedTabs } = Cu.import("resource://services-sync/SyncedTabs.jsm", {}); +let { TabListComponent } = Cu.import("resource:///modules/syncedtabs/TabListComponent.js", {}); +let { SyncedTabsListStore } = Cu.import("resource:///modules/syncedtabs/SyncedTabsListStore.js", {}); +let { View } = Cu.import("resource:///modules/syncedtabs/TabListView.js", {}); + +const ACTION_METHODS = [ + "onSelectRow", + "onOpenTab", + "onOpenTabs", + "onMoveSelectionDown", + "onMoveSelectionUp", + "onToggleBranch", + "onBookmarkTab", + "onSyncRefresh", + "onFilter", + "onClearFilter", + "onFilterFocus", + "onFilterBlur", +]; + +add_task(function* testInitUninit() { + let store = new SyncedTabsListStore(); + let ViewMock = sinon.stub(); + let view = {render() {}, destroy() {}}; + + ViewMock.returns(view); + + sinon.spy(view, 'render'); + sinon.spy(view, 'destroy'); + + sinon.spy(store, "on"); + sinon.stub(store, "getData"); + sinon.stub(store, "focusInput"); + + let component = new TabListComponent({window, store, View: ViewMock, SyncedTabs}); + + for (let action of ACTION_METHODS) { + sinon.stub(component, action); + } + + component.init(); + + Assert.ok(ViewMock.calledWithNew(), "view is instantiated"); + Assert.ok(store.on.calledOnce, "listener is added to store"); + Assert.equal(store.on.args[0][0], "change"); + Assert.ok(view.render.calledWith({clients: []}), + "render is called on view instance"); + Assert.ok(store.getData.calledOnce, "store gets initial data"); + Assert.ok(store.focusInput.calledOnce, "input field is focused"); + + for (let method of ACTION_METHODS) { + let action = ViewMock.args[0][1][method]; + Assert.ok(action, method + " action is passed to View"); + action("foo", "bar"); + Assert.ok(component[method].calledWith("foo", "bar"), + method + " action passed to View triggers the component method with args"); + } + + store.emit("change", "mock state"); + Assert.ok(view.render.secondCall.calledWith("mock state"), + "view.render is called on state change"); + + component.uninit(); + Assert.ok(view.destroy.calledOnce, "view is destroyed on uninit"); +}); + +add_task(function* testActions() { + let store = new SyncedTabsListStore(); + let chromeWindowMock = { + gBrowser: { + loadTabs() {}, + }, + }; + let getChromeWindowMock = sinon.stub(); + getChromeWindowMock.returns(chromeWindowMock); + let clipboardHelperMock = { + copyString() {}, + }; + let windowMock = { + top: { + PlacesCommandHook: { + bookmarkLink() { return Promise.resolve(); } + }, + PlacesUtils: { bookmarksMenuFolderId: "id" } + }, + getBrowserURL() {}, + openDialog() {}, + openUILinkIn() {} + }; + let component = new TabListComponent({ + window: windowMock, store, View: null, SyncedTabs, + clipboardHelper: clipboardHelperMock, + getChromeWindow: getChromeWindowMock }); + + sinon.stub(store, "getData"); + component.onFilter("query"); + Assert.ok(store.getData.calledWith("query")); + + sinon.stub(store, "clearFilter"); + component.onClearFilter(); + Assert.ok(store.clearFilter.called); + + sinon.stub(store, "focusInput"); + component.onFilterFocus(); + Assert.ok(store.focusInput.called); + + sinon.stub(store, "blurInput"); + component.onFilterBlur(); + Assert.ok(store.blurInput.called); + + sinon.stub(store, "selectRow"); + component.onSelectRow([-1, -1]); + Assert.ok(store.selectRow.calledWith(-1, -1)); + + sinon.stub(store, "moveSelectionDown"); + component.onMoveSelectionDown(); + Assert.ok(store.moveSelectionDown.called); + + sinon.stub(store, "moveSelectionUp"); + component.onMoveSelectionUp(); + Assert.ok(store.moveSelectionUp.called); + + sinon.stub(store, "toggleBranch"); + component.onToggleBranch("foo-id"); + Assert.ok(store.toggleBranch.calledWith("foo-id")); + + sinon.spy(windowMock.top.PlacesCommandHook, "bookmarkLink"); + component.onBookmarkTab("uri", "title"); + Assert.equal(windowMock.top.PlacesCommandHook.bookmarkLink.args[0][1], "uri"); + Assert.equal(windowMock.top.PlacesCommandHook.bookmarkLink.args[0][2], "title"); + + sinon.spy(windowMock, "openUILinkIn"); + component.onOpenTab("uri", "where", "params"); + Assert.ok(windowMock.openUILinkIn.calledWith("uri", "where", "params")); + + sinon.spy(chromeWindowMock.gBrowser, "loadTabs"); + let tabsToOpen = ["uri1", "uri2"]; + component.onOpenTabs(tabsToOpen, "where"); + Assert.ok(getChromeWindowMock.calledWith(windowMock)); + Assert.ok(chromeWindowMock.gBrowser.loadTabs.calledWith(tabsToOpen, false, false)); + component.onOpenTabs(tabsToOpen, "tabshifted"); + Assert.ok(chromeWindowMock.gBrowser.loadTabs.calledWith(tabsToOpen, true, false)); + + sinon.spy(clipboardHelperMock, "copyString"); + component.onCopyTabLocation("uri"); + Assert.ok(clipboardHelperMock.copyString.calledWith("uri")); + + sinon.stub(SyncedTabs, "syncTabs"); + component.onSyncRefresh(); + Assert.ok(SyncedTabs.syncTabs.calledWith(true)); + SyncedTabs.syncTabs.restore(); +}); + diff --git a/browser/components/syncedtabs/test/xpcshell/xpcshell.ini b/browser/components/syncedtabs/test/xpcshell/xpcshell.ini new file mode 100644 index 000000000..1cb8dcb7a --- /dev/null +++ b/browser/components/syncedtabs/test/xpcshell/xpcshell.ini @@ -0,0 +1,10 @@ +[DEFAULT] +head = head.js +tail = +firefox-appdir = browser + +[test_EventEmitter.js] +[test_SyncedTabsDeckStore.js] +[test_SyncedTabsListStore.js] +[test_SyncedTabsDeckComponent.js] +[test_TabListComponent.js] diff --git a/browser/components/syncedtabs/util.js b/browser/components/syncedtabs/util.js new file mode 100644 index 000000000..e09a1a528 --- /dev/null +++ b/browser/components/syncedtabs/util.js @@ -0,0 +1,23 @@ +/* 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; + +this.EXPORTED_SYMBOLS = [ + "getChromeWindow" +]; + +// Get the chrome (ie, browser) window hosting this content. +function getChromeWindow(window) { + return window + .QueryInterface(Ci.nsIInterfaceRequestor) + .getInterface(Ci.nsIWebNavigation) + .QueryInterface(Ci.nsIDocShellTreeItem) + .rootTreeItem + .QueryInterface(Ci.nsIInterfaceRequestor) + .getInterface(Ci.nsIDOMWindow) + .wrappedJSObject; +} |