From 26a029d407be480d791972afb5975cf62c9360a6 Mon Sep 17 00:00:00 2001 From: Daniel Baumann Date: Fri, 19 Apr 2024 02:47:55 +0200 Subject: Adding upstream version 124.0.1. Signed-off-by: Daniel Baumann --- browser/components/syncedtabs/EventEmitter.sys.mjs | 36 ++ .../syncedtabs/SyncedTabsDeckComponent.sys.mjs | 173 ++++++ .../syncedtabs/SyncedTabsDeckStore.sys.mjs | 54 ++ .../syncedtabs/SyncedTabsDeckView.sys.mjs | 90 +++ .../syncedtabs/SyncedTabsListStore.sys.mjs | 253 ++++++++ .../components/syncedtabs/TabListComponent.sys.mjs | 147 +++++ browser/components/syncedtabs/TabListView.sys.mjs | 657 +++++++++++++++++++++ browser/components/syncedtabs/jar.mn | 7 + browser/components/syncedtabs/moz.build | 23 + browser/components/syncedtabs/sidebar.js | 41 ++ browser/components/syncedtabs/sidebar.xhtml | 157 +++++ .../syncedtabs/test/browser/browser.toml | 4 + .../test/browser/browser_sidebar_syncedtabslist.js | 646 ++++++++++++++++++++ browser/components/syncedtabs/test/browser/head.js | 3 + .../components/syncedtabs/test/xpcshell/head.js | 8 + .../syncedtabs/test/xpcshell/test_EventEmitter.js | 36 ++ .../test/xpcshell/test_SyncedTabsDeckComponent.js | 263 +++++++++ .../test/xpcshell/test_SyncedTabsDeckStore.js | 69 +++ .../test/xpcshell/test_SyncedTabsListStore.js | 289 +++++++++ .../test/xpcshell/test_TabListComponent.js | 190 ++++++ .../syncedtabs/test/xpcshell/xpcshell.toml | 14 + browser/components/syncedtabs/util.sys.mjs | 8 + 22 files changed, 3168 insertions(+) create mode 100644 browser/components/syncedtabs/EventEmitter.sys.mjs create mode 100644 browser/components/syncedtabs/SyncedTabsDeckComponent.sys.mjs create mode 100644 browser/components/syncedtabs/SyncedTabsDeckStore.sys.mjs create mode 100644 browser/components/syncedtabs/SyncedTabsDeckView.sys.mjs create mode 100644 browser/components/syncedtabs/SyncedTabsListStore.sys.mjs create mode 100644 browser/components/syncedtabs/TabListComponent.sys.mjs create mode 100644 browser/components/syncedtabs/TabListView.sys.mjs create mode 100644 browser/components/syncedtabs/jar.mn create mode 100644 browser/components/syncedtabs/moz.build create mode 100644 browser/components/syncedtabs/sidebar.js create mode 100644 browser/components/syncedtabs/sidebar.xhtml create mode 100644 browser/components/syncedtabs/test/browser/browser.toml create mode 100644 browser/components/syncedtabs/test/browser/browser_sidebar_syncedtabslist.js create mode 100644 browser/components/syncedtabs/test/browser/head.js create mode 100644 browser/components/syncedtabs/test/xpcshell/head.js create mode 100644 browser/components/syncedtabs/test/xpcshell/test_EventEmitter.js create mode 100644 browser/components/syncedtabs/test/xpcshell/test_SyncedTabsDeckComponent.js create mode 100644 browser/components/syncedtabs/test/xpcshell/test_SyncedTabsDeckStore.js create mode 100644 browser/components/syncedtabs/test/xpcshell/test_SyncedTabsListStore.js create mode 100644 browser/components/syncedtabs/test/xpcshell/test_TabListComponent.js create mode 100644 browser/components/syncedtabs/test/xpcshell/xpcshell.toml create mode 100644 browser/components/syncedtabs/util.sys.mjs (limited to 'browser/components/syncedtabs') diff --git a/browser/components/syncedtabs/EventEmitter.sys.mjs b/browser/components/syncedtabs/EventEmitter.sys.mjs new file mode 100644 index 0000000000..ed026dc173 --- /dev/null +++ b/browser/components/syncedtabs/EventEmitter.sys.mjs @@ -0,0 +1,36 @@ +/* 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/. */ + +// Simple event emitter abstraction for storage objects to use. +export 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) { + console.error(e); + } + } + }, +}; diff --git a/browser/components/syncedtabs/SyncedTabsDeckComponent.sys.mjs b/browser/components/syncedtabs/SyncedTabsDeckComponent.sys.mjs new file mode 100644 index 0000000000..47571f789d --- /dev/null +++ b/browser/components/syncedtabs/SyncedTabsDeckComponent.sys.mjs @@ -0,0 +1,173 @@ +/* This Source Code Form is subject to the terms of the Mozilla Public + * License, v. 2.0. If a copy of the MPL was not distributed with this + * file, You can obtain one at http://mozilla.org/MPL/2.0/. */ + +import { SyncedTabsDeckStore } from "resource:///modules/syncedtabs/SyncedTabsDeckStore.sys.mjs"; +import { SyncedTabsDeckView } from "resource:///modules/syncedtabs/SyncedTabsDeckView.sys.mjs"; +import { SyncedTabsListStore } from "resource:///modules/syncedtabs/SyncedTabsListStore.sys.mjs"; +import { TabListComponent } from "resource:///modules/syncedtabs/TabListComponent.sys.mjs"; +import { TabListView } from "resource:///modules/syncedtabs/TabListView.sys.mjs"; +import { getChromeWindow } from "resource:///modules/syncedtabs/util.sys.mjs"; +import { UIState } from "resource://services-sync/UIState.sys.mjs"; + +/* 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. + */ + +export function SyncedTabsDeckComponent({ + window, + SyncedTabs, + deckStore, + listStore, + listComponent, + DeckView, + getChromeWindowMock, +}) { + this._window = window; + this._SyncedTabs = SyncedTabs; + 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", + LOGIN_FAILED: "reauth", + NOT_AUTHED_INFO: "notAuthedInfo", + SYNC_DISABLED: "syncDisabled", + SINGLE_DEVICE_INFO: "singleDeviceInfo", + TABS_DISABLED: "tabs-disabled", + UNVERIFIED: "unverified", + }, + + get container() { + return this._deckView ? this._deckView.container : null; + }, + + init() { + Services.obs.addObserver(this, this._SyncedTabs.TOPIC_TABS_CHANGED); + Services.obs.addObserver(this, UIState.ON_UPDATE); + + // Add app locale change support for HTML sidebar + Services.obs.addObserver(this, "intl:app-locales-changed"); + this.updateDir(); + + // Go ahead and trigger sync + this._SyncedTabs.syncTabs().catch(console.error); + + this._deckView = new this._DeckView(this._window, this.tabListComponent, { + onConnectDeviceClick: event => this.openConnectDevice(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, UIState.ON_UPDATE); + Services.obs.removeObserver(this, "intl:app-locales-changed"); + this._deckView.destroy(); + }, + + observe(subject, topic, data) { + switch (topic) { + case this._SyncedTabs.TOPIC_TABS_CHANGED: + this._syncedTabsListStore.getData(); + this.updatePanel(); + break; + case UIState.ON_UPDATE: + this.updatePanel(); + break; + case "intl:app-locales-changed": + this.updateDir(); + break; + default: + break; + } + }, + + async getPanelStatus() { + try { + const state = UIState.get(); + const { status } = state; + if (status == UIState.STATUS_NOT_CONFIGURED) { + return this.PANELS.NOT_AUTHED_INFO; + } else if (status == UIState.STATUS_LOGIN_FAILED) { + return this.PANELS.LOGIN_FAILED; + } else if (status == UIState.STATUS_NOT_VERIFIED) { + return this.PANELS.UNVERIFIED; + } else if (!state.syncEnabled) { + return this.PANELS.SYNC_DISABLED; + } else if (!this._SyncedTabs.isConfiguredToSyncTabs) { + return this.PANELS.TABS_DISABLED; + } else if (!this._SyncedTabs.hasSyncedThisSession) { + return this.PANELS.TABS_FETCHING; + } + const clients = await this._SyncedTabs.getTabClients(); + if (clients.length) { + return this.PANELS.TABS_CONTAINER; + } + return this.PANELS.SINGLE_DEVICE_INFO; + } catch (err) { + console.error(err); + return this.PANELS.NOT_AUTHED_INFO; + } + }, + + updateDir() { + // If the HTML document doesn't exist, we can't update the window + if (!this._window.document) { + return; + } + + if (Services.locale.isAppLocaleRTL) { + this._window.document.body.dir = "rtl"; + } else { + this._window.document.body.dir = "ltr"; + } + }, + + updatePanel() { + // return promise for tests + return this.getPanelStatus() + .then(panelId => this._deckStore.selectPanel(panelId)) + .catch(console.error); + }, + + openSyncPrefs() { + this._getChromeWindow(this._window).gSync.openPrefs("tabs-sidebar"); + }, + + openConnectDevice() { + this._getChromeWindow(this._window).gSync.openConnectAnotherDevice( + "tabs-sidebar" + ); + }, +}; diff --git a/browser/components/syncedtabs/SyncedTabsDeckStore.sys.mjs b/browser/components/syncedtabs/SyncedTabsDeckStore.sys.mjs new file mode 100644 index 0000000000..bea52bc55a --- /dev/null +++ b/browser/components/syncedtabs/SyncedTabsDeckStore.sys.mjs @@ -0,0 +1,54 @@ +/* This Source Code Form is subject to the terms of the Mozilla Public + * License, v. 2.0. If a copy of the MPL was not distributed with this + * file, You can obtain one at http://mozilla.org/MPL/2.0/. */ + +import { EventEmitter } from "resource:///modules/syncedtabs/EventEmitter.sys.mjs"; + +/** + * 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. + */ +export 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.includes(panelId) || this._selectedPanel === panelId) { + return; + } + this._selectedPanel = panelId; + this._change(true); + }, + + /** + * Update the set of panels in the deck and trigger a change event. + * + * @param {Array} panels - an array of IDs for each panel in the deck. + */ + setPanels(panels) { + if (panels === this._panels) { + return; + } + this._panels = panels || []; + this._change(); + }, +}); diff --git a/browser/components/syncedtabs/SyncedTabsDeckView.sys.mjs b/browser/components/syncedtabs/SyncedTabsDeckView.sys.mjs new file mode 100644 index 0000000000..aacae71fa5 --- /dev/null +++ b/browser/components/syncedtabs/SyncedTabsDeckView.sys.mjs @@ -0,0 +1,90 @@ +/* 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/. */ + +/** + * 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. + */ +export 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._attachListeners(); + this.update(state); + }, + + 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.firstChild.remove(); + } + }, + + _attachListeners() { + let syncPrefLinks = this.container.querySelectorAll(".sync-prefs"); + for (let link of syncPrefLinks) { + link.addEventListener("click", this.props.onSyncPrefClick); + } + this.container + .querySelector(".connect-device") + .addEventListener("click", this.props.onConnectDeviceClick); + }, +}; diff --git a/browser/components/syncedtabs/SyncedTabsListStore.sys.mjs b/browser/components/syncedtabs/SyncedTabsListStore.sys.mjs new file mode 100644 index 0000000000..67adcfdace --- /dev/null +++ b/browser/components/syncedtabs/SyncedTabsListStore.sys.mjs @@ -0,0 +1,253 @@ +/* This Source Code Form is subject to the terms of the Mozilla Public + * License, v. 2.0. If a copy of the MPL was not distributed with this + * file, You can obtain one at http://mozilla.org/MPL/2.0/. */ + +import { EventEmitter } from "resource:///modules/syncedtabs/EventEmitter.sys.mjs"; + +/** + * 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. + */ +export 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"); + // Record the telemetry event + let extraOptions = { + tab_pos: this._selectedRow[1].toString(), + filter: this.filter, + }; + this._SyncedTabs.recordSyncedTabsTelemetry( + "synced_tabs_sidebar", + "click", + extraOptions + ); + }, + + _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(console.error); + }, +}); diff --git a/browser/components/syncedtabs/TabListComponent.sys.mjs b/browser/components/syncedtabs/TabListComponent.sys.mjs new file mode 100644 index 0000000000..cda3376dc2 --- /dev/null +++ b/browser/components/syncedtabs/TabListComponent.sys.mjs @@ -0,0 +1,147 @@ +/* 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/. */ + +let log = ChromeUtils.importESModule( + "resource://gre/modules/Log.sys.mjs" +).Log.repository.getLogger("Sync.RemoteTabs"); + +const lazy = {}; + +ChromeUtils.defineESModuleGetters(lazy, { + OpenInTabsUtils: "resource:///modules/OpenInTabsUtils.sys.mjs", +}); + +/** + * 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. + */ + +export 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(uri, title).catch( + console.error + ); + }, + + onOpenTab(url, where, params) { + this._window.openTrustedLinkIn(url, where, params); + }, + + onOpenTabs(urls, where) { + if (!lazy.OpenInTabsUtils.confirmOpenInTabs(urls.length, this._window)) { + return; + } + if (where == "window") { + this._window.openDialog( + this._window.AppConstants.BROWSER_CHROME_URL, + "_blank", + "chrome,dialog=no,all", + urls.join("|") + ); + } else { + let loadInBackground = where == "tabshifted"; + this._getChromeWindow(this._window).gBrowser.loadTabs(urls, { + inBackground: loadInBackground, + replace: false, + triggeringPrincipal: + Services.scriptSecurityManager.getSystemPrincipal(), + }); + } + }, + + onCopyTabLocation(url) { + this._clipboardHelper.copyString(url); + }, + + onSyncRefresh() { + this._SyncedTabs.syncTabs(true); + }, +}; diff --git a/browser/components/syncedtabs/TabListView.sys.mjs b/browser/components/syncedtabs/TabListView.sys.mjs new file mode 100644 index 0000000000..a280a0fe0f --- /dev/null +++ b/browser/components/syncedtabs/TabListView.sys.mjs @@ -0,0 +1,657 @@ +/* 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/. */ + +const lazy = {}; + +ChromeUtils.defineESModuleGetters(lazy, { + PrivateBrowsingUtils: "resource://gre/modules/PrivateBrowsingUtils.sys.mjs", +}); + +import { getChromeWindow } from "resource:///modules/syncedtabs/util.sys.mjs"; + +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. + */ +export 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.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.firstElementChild) { + const firstTab = this.list.firstElementChild.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); + }); + }, + + _updateLastSyncTitle(lastModified, itemNode) { + let lastSync = new Date(lastModified); + let lastSyncTitle = getChromeWindow(this._window).gSync.formatLastSyncDate( + lastSync + ); + itemNode.setAttribute("title", lastSyncTitle); + }, + + _renderClient(client) { + let itemNode = client.tabs.length + ? this._createClient(client) + : this._createEmptyClient(client); + + itemNode.addEventListener("mouseover", () => + this._updateLastSyncTitle(client.lastModified, itemNode) + ); + + 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() { + return this._doc.importNode(this._clientTemplate.content, true) + .firstElementChild; + }, + + _createEmptyClient() { + return this._doc.importNode(this._emptyClientTemplate.content, true) + .firstElementChild; + }, + + _createTab() { + return this._doc.importNode(this._tabTemplate.content, true) + .firstElementChild; + }, + + _clearChilden(node) { + let parent = node || this.container; + while (parent.firstChild) { + parent.firstChild.remove(); + } + }, + + // These listeners are attached only once, when we initialize the view + _attachFixedListeners() { + this.tabsFilter.addEventListener("command", this.onFilter.bind(this)); + this.tabsFilter.addEventListener("focus", this.onFilterFocus.bind(this)); + this.tabsFilter.addEventListener("blur", this.onFilterBlur.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) { + this.tabsFilter.value = state.filter; + if (state.inputFocused) { + this.tabsFilter.focus(); + } + }, + + /** + * 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); + this._updateLastSyncTitle(item.lastModified, itemNode); + 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.focused) { + itemNode.focus(); + } + itemNode.setAttribute("clientType", item.clientType); + 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); + } + }, + + onOpenSelectedInContainerTab(event) { + let item = this._getSelectedTabNode(); + if (item) { + this.props.onOpenTab(item.dataset.url, "tab", { + userContextId: parseInt(event.target?.dataset.usercontextid), + }); + } + }, + + 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(); + } + }, + + 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 "SyncedTabsOpenSelectedInContainerTabMenu": + this.onOpenSelectedInContainerTab(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.inputField) { + 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.firstElementChild; + + while (el) { + let show = false; + if (showTabOptions) { + if (el.getAttribute("id") == "syncedTabsOpenSelectedInPrivateWindow") { + show = lazy.PrivateBrowsingUtils.enabled; + } else if ( + el.getAttribute("id") === "syncedTabsOpenSelectedInContainerTab" + ) { + show = + Services.prefs.getBoolPref("privacy.userContext.enabled", false) && + !lazy.PrivateBrowsingUtils.isWindowPrivate( + getChromeWindow(this._window) + ); + } else if ( + el.getAttribute("id") != "syncedTabsOpenAllInTabs" && + el.getAttribute("id") != "syncedTabsManageDevices" + ) { + show = true; + } + } else if (el.getAttribute("id") == "syncedTabsOpenAllInTabs") { + const tabs = item.querySelectorAll(".item-tabs-list > .item.tab"); + show = !!tabs.length; + } else if (el.getAttribute("id") == "syncedTabsRefresh") { + show = true; + } else if (el.getAttribute("id") == "syncedTabsManageDevices") { + show = true; + } + el.hidden = !show; + + el = el.nextElementSibling; + } + }, + + /** + * Find the parent item element, from a given child element. + * + * @param {Element} node - Child element. + * @returns {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.children, 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").children; + const urls = [...tabs].map(tab => tab.dataset.url); + this.props.onOpenTabs(urls, where); + }, +}; diff --git a/browser/components/syncedtabs/jar.mn b/browser/components/syncedtabs/jar.mn new file mode 100644 index 0000000000..ba2b105a17 --- /dev/null +++ b/browser/components/syncedtabs/jar.mn @@ -0,0 +1,7 @@ +# This Source Code Form is subject to the terms of the Mozilla Public +# License, v. 2.0. If a copy of the MPL was not distributed with this +# file, You can obtain one at http://mozilla.org/MPL/2.0/. + +browser.jar: + content/browser/syncedtabs/sidebar.xhtml + content/browser/syncedtabs/sidebar.js diff --git a/browser/components/syncedtabs/moz.build b/browser/components/syncedtabs/moz.build new file mode 100644 index 0000000000..8a702d3906 --- /dev/null +++ b/browser/components/syncedtabs/moz.build @@ -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/. + +JAR_MANIFESTS += ["jar.mn"] + +BROWSER_CHROME_MANIFESTS += ["test/browser/browser.toml"] + +XPCSHELL_TESTS_MANIFESTS += ["test/xpcshell/xpcshell.toml"] + +EXTRA_JS_MODULES.syncedtabs += [ + "EventEmitter.sys.mjs", + "SyncedTabsDeckComponent.sys.mjs", + "SyncedTabsDeckStore.sys.mjs", + "SyncedTabsDeckView.sys.mjs", + "SyncedTabsListStore.sys.mjs", + "TabListComponent.sys.mjs", + "TabListView.sys.mjs", + "util.sys.mjs", +] + +with Files("**"): + BUG_COMPONENT = ("Firefox", "Sync") diff --git a/browser/components/syncedtabs/sidebar.js b/browser/components/syncedtabs/sidebar.js new file mode 100644 index 0000000000..bf65bd96d5 --- /dev/null +++ b/browser/components/syncedtabs/sidebar.js @@ -0,0 +1,41 @@ +/* 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 { SyncedTabs } = ChromeUtils.importESModule( + "resource://services-sync/SyncedTabs.sys.mjs" +); +const { SyncedTabsDeckComponent } = ChromeUtils.importESModule( + "resource:///modules/syncedtabs/SyncedTabsDeckComponent.sys.mjs" +); + +var syncedTabsDeckComponent = new SyncedTabsDeckComponent({ + window, + SyncedTabs, +}); + +let onLoaded = () => { + window.top.MozXULElement.insertFTLIfNeeded("browser/syncedTabs.ftl"); + window.top.document + .getElementById("SyncedTabsSidebarContext") + .querySelectorAll("[data-lazy-l10n-id]") + .forEach(el => { + el.setAttribute("data-l10n-id", el.getAttribute("data-lazy-l10n-id")); + el.removeAttribute("data-lazy-l10n-id"); + }); + syncedTabsDeckComponent.init(); + document + .getElementById("template-container") + .appendChild(syncedTabsDeckComponent.container); +}; + +let onUnloaded = () => { + removeEventListener("DOMContentLoaded", onLoaded); + removeEventListener("unload", onUnloaded); + syncedTabsDeckComponent.uninit(); +}; + +addEventListener("DOMContentLoaded", onLoaded); +addEventListener("unload", onUnloaded); diff --git a/browser/components/syncedtabs/sidebar.xhtml b/browser/components/syncedtabs/sidebar.xhtml new file mode 100644 index 0000000000..8091f61aee --- /dev/null +++ b/browser/components/syncedtabs/sidebar.xhtml @@ -0,0 +1,157 @@ + + + + + + +