summaryrefslogtreecommitdiff
path: root/application/basilisk/components/syncedtabs
diff options
context:
space:
mode:
Diffstat (limited to 'application/basilisk/components/syncedtabs')
-rw-r--r--application/basilisk/components/syncedtabs/EventEmitter.jsm45
-rw-r--r--application/basilisk/components/syncedtabs/SyncedTabsDeckComponent.js172
-rw-r--r--application/basilisk/components/syncedtabs/SyncedTabsDeckStore.js60
-rw-r--r--application/basilisk/components/syncedtabs/SyncedTabsDeckView.js116
-rw-r--r--application/basilisk/components/syncedtabs/SyncedTabsListStore.js235
-rw-r--r--application/basilisk/components/syncedtabs/TabListComponent.js142
-rw-r--r--application/basilisk/components/syncedtabs/TabListView.js601
-rw-r--r--application/basilisk/components/syncedtabs/jar.mn7
-rw-r--r--application/basilisk/components/syncedtabs/moz.build16
-rw-r--r--application/basilisk/components/syncedtabs/sidebar.js30
-rw-r--r--application/basilisk/components/syncedtabs/sidebar.xhtml114
-rw-r--r--application/basilisk/components/syncedtabs/util.js23
12 files changed, 1561 insertions, 0 deletions
diff --git a/application/basilisk/components/syncedtabs/EventEmitter.jsm b/application/basilisk/components/syncedtabs/EventEmitter.jsm
new file mode 100644
index 0000000000..443313ecee
--- /dev/null
+++ b/application/basilisk/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/application/basilisk/components/syncedtabs/SyncedTabsDeckComponent.js b/application/basilisk/components/syncedtabs/SyncedTabsDeckComponent.js
new file mode 100644
index 0000000000..a63ecc34e3
--- /dev/null
+++ b/application/basilisk/components/syncedtabs/SyncedTabsDeckComponent.js
@@ -0,0 +1,172 @@
+/* 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,
+ 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);
+ Services.obs.addObserver(this, "weave:service:login:change", 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);
+ Services.obs.removeObserver(this, "weave:service:login:change");
+ 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:
+ case "weave:service:login:change":
+ 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 || this._getChromeWindow(this._window).gSyncUI.loginFailed()) {
+ 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/application/basilisk/components/syncedtabs/SyncedTabsDeckStore.js b/application/basilisk/components/syncedtabs/SyncedTabsDeckStore.js
new file mode 100644
index 0000000000..eef594a519
--- /dev/null
+++ b/application/basilisk/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});
+ },
+
+ /**
+ * 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/application/basilisk/components/syncedtabs/SyncedTabsDeckView.js b/application/basilisk/components/syncedtabs/SyncedTabsDeckView.js
new file mode 100644
index 0000000000..4a31080297
--- /dev/null
+++ b/application/basilisk/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/application/basilisk/components/syncedtabs/SyncedTabsListStore.js b/application/basilisk/components/syncedtabs/SyncedTabsListStore.js
new file mode 100644
index 0000000000..8f03d9a898
--- /dev/null
+++ b/application/basilisk/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/application/basilisk/components/syncedtabs/TabListComponent.js b/application/basilisk/components/syncedtabs/TabListComponent.js
new file mode 100644
index 0000000000..d3aace8f9c
--- /dev/null
+++ b/application/basilisk/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/application/basilisk/components/syncedtabs/TabListView.js b/application/basilisk/components/syncedtabs/TabListView.js
new file mode 100644
index 0000000000..a8d1ed9c66
--- /dev/null
+++ b/application/basilisk/components/syncedtabs/TabListView.js
@@ -0,0 +1,601 @@
+/* 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") {
+ this._openAllClientTabs(itemNode, 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);
+ }
+ },
+
+ onOpenAllInTabs() {
+ let item = this._getSelectedClientNode();
+ if (item) {
+ this._openAllClientTabs(item, "tab");
+ }
+ },
+
+ 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;
+ },
+
+ _getSelectedClientNode() {
+ let item = this.container.querySelector(".item.selected");
+ if (this._isClient(item)) {
+ 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 "syncedTabsOpenAllInTabs":
+ this.onOpenAllInTabs();
+ 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) {
+ let show = false;
+ if (showTabOptions) {
+ if (el.getAttribute("id") != "syncedTabsOpenAllInTabs") {
+ show = true;
+ }
+ } else if (el.getAttribute("id") == "syncedTabsOpenAllInTabs") {
+ const tabs = item.querySelectorAll(".item-tabs-list > .item.tab");
+ show = tabs.length > 0;
+ } else if (el.getAttribute("id") == "syncedTabsRefresh") {
+ show = true;
+ }
+ el.hidden = !show;
+
+ 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");
+ },
+
+ _isClient(item) {
+ return item && item.classList.contains("client");
+ },
+
+ _openAllClientTabs(clientNode, where) {
+ const tabs = clientNode.querySelector(".item-tabs-list").childNodes;
+ const urls = [...tabs].map(tab => tab.dataset.url);
+ this.props.onOpenTabs(urls, where);
+ }
+};
diff --git a/application/basilisk/components/syncedtabs/jar.mn b/application/basilisk/components/syncedtabs/jar.mn
new file mode 100644
index 0000000000..ba2b105a17
--- /dev/null
+++ b/application/basilisk/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/application/basilisk/components/syncedtabs/moz.build b/application/basilisk/components/syncedtabs/moz.build
new file mode 100644
index 0000000000..409025830e
--- /dev/null
+++ b/application/basilisk/components/syncedtabs/moz.build
@@ -0,0 +1,16 @@
+# 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.syncedtabs += [
+ 'EventEmitter.jsm',
+ 'SyncedTabsDeckComponent.js',
+ 'SyncedTabsDeckStore.js',
+ 'SyncedTabsDeckView.js',
+ 'SyncedTabsListStore.js',
+ 'TabListComponent.js',
+ 'TabListView.js',
+ 'util.js',
+]
diff --git a/application/basilisk/components/syncedtabs/sidebar.js b/application/basilisk/components/syncedtabs/sidebar.js
new file mode 100644
index 0000000000..84df95e9de
--- /dev/null
+++ b/application/basilisk/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/application/basilisk/components/syncedtabs/sidebar.xhtml b/application/basilisk/components/syncedtabs/sidebar.xhtml
new file mode 100644
index 0000000000..3efcbea0e4
--- /dev/null
+++ b/application/basilisk/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/application/basilisk/components/syncedtabs/util.js b/application/basilisk/components/syncedtabs/util.js
new file mode 100644
index 0000000000..e09a1a5284
--- /dev/null
+++ b/application/basilisk/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;
+}