From e72ef92b5bdc43cd2584198e2e54e951b70299e8 Mon Sep 17 00:00:00 2001 From: "Matt A. Tobin" Date: Fri, 2 Feb 2018 03:32:58 -0500 Subject: Add Basilisk --- .../components/syncedtabs/EventEmitter.jsm | 45 ++ .../syncedtabs/SyncedTabsDeckComponent.js | 172 ++++++ .../components/syncedtabs/SyncedTabsDeckStore.js | 60 ++ .../components/syncedtabs/SyncedTabsDeckView.js | 116 ++++ .../components/syncedtabs/SyncedTabsListStore.js | 235 ++++++++ .../components/syncedtabs/TabListComponent.js | 142 +++++ .../basilisk/components/syncedtabs/TabListView.js | 601 +++++++++++++++++++++ application/basilisk/components/syncedtabs/jar.mn | 7 + .../basilisk/components/syncedtabs/moz.build | 16 + .../basilisk/components/syncedtabs/sidebar.js | 30 + .../basilisk/components/syncedtabs/sidebar.xhtml | 114 ++++ application/basilisk/components/syncedtabs/util.js | 23 + 12 files changed, 1561 insertions(+) create mode 100644 application/basilisk/components/syncedtabs/EventEmitter.jsm create mode 100644 application/basilisk/components/syncedtabs/SyncedTabsDeckComponent.js create mode 100644 application/basilisk/components/syncedtabs/SyncedTabsDeckStore.js create mode 100644 application/basilisk/components/syncedtabs/SyncedTabsDeckView.js create mode 100644 application/basilisk/components/syncedtabs/SyncedTabsListStore.js create mode 100644 application/basilisk/components/syncedtabs/TabListComponent.js create mode 100644 application/basilisk/components/syncedtabs/TabListView.js create mode 100644 application/basilisk/components/syncedtabs/jar.mn create mode 100644 application/basilisk/components/syncedtabs/moz.build create mode 100644 application/basilisk/components/syncedtabs/sidebar.js create mode 100644 application/basilisk/components/syncedtabs/sidebar.xhtml create mode 100644 application/basilisk/components/syncedtabs/util.js (limited to 'application/basilisk/components/syncedtabs') diff --git a/application/basilisk/components/syncedtabs/EventEmitter.jsm b/application/basilisk/components/syncedtabs/EventEmitter.jsm new file mode 100644 index 000000000..443313ece --- /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 000000000..a63ecc34e --- /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 000000000..eef594a51 --- /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 000000000..4a3108029 --- /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 000000000..8f03d9a89 --- /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 000000000..d3aace8f9 --- /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 000000000..a8d1ed9c6 --- /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 000000000..ba2b105a1 --- /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 000000000..409025830 --- /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 000000000..84df95e9d --- /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 000000000..3efcbea0e --- /dev/null +++ b/application/basilisk/components/syncedtabs/sidebar.xhtml @@ -0,0 +1,114 @@ + + + + + %browserDTD; + + %globalDTD; + + %syncBrandDTD; +]> + + + +