summaryrefslogtreecommitdiffstats
path: root/browser/base/content/browser-sync.js
diff options
context:
space:
mode:
Diffstat (limited to '')
-rw-r--r--browser/base/content/browser-sync.js1963
1 files changed, 1963 insertions, 0 deletions
diff --git a/browser/base/content/browser-sync.js b/browser/base/content/browser-sync.js
new file mode 100644
index 0000000000..1280fd8305
--- /dev/null
+++ b/browser/base/content/browser-sync.js
@@ -0,0 +1,1963 @@
+/* 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/. */
+
+// This file is loaded into the browser window scope.
+/* eslint-env mozilla/browser-window */
+
+const { UIState } = ChromeUtils.importESModule(
+ "resource://services-sync/UIState.sys.mjs"
+);
+
+ChromeUtils.defineESModuleGetters(this, {
+ EnsureFxAccountsWebChannel:
+ "resource://gre/modules/FxAccountsWebChannel.sys.mjs",
+
+ ExperimentAPI: "resource://nimbus/ExperimentAPI.sys.mjs",
+ FxAccounts: "resource://gre/modules/FxAccounts.sys.mjs",
+ SyncedTabs: "resource://services-sync/SyncedTabs.sys.mjs",
+ Weave: "resource://services-sync/main.sys.mjs",
+});
+
+const MIN_STATUS_ANIMATION_DURATION = 1600;
+
+this.SyncedTabsPanelList = class SyncedTabsPanelList {
+ static sRemoteTabsDeckIndices = {
+ DECKINDEX_TABS: 0,
+ DECKINDEX_FETCHING: 1,
+ DECKINDEX_TABSDISABLED: 2,
+ DECKINDEX_NOCLIENTS: 3,
+ };
+
+ static sRemoteTabsPerPage = 25;
+ static sRemoteTabsNextPageMinTabs = 5;
+
+ constructor(panelview, deck, tabsList, separator) {
+ this.QueryInterface = ChromeUtils.generateQI([
+ "nsIObserver",
+ "nsISupportsWeakReference",
+ ]);
+
+ Services.obs.addObserver(this, SyncedTabs.TOPIC_TABS_CHANGED, true);
+ this.deck = deck;
+ this.tabsList = tabsList;
+ this.separator = separator;
+ this._showSyncedTabsPromise = Promise.resolve();
+
+ this.createSyncedTabs();
+ }
+
+ observe(subject, topic, data) {
+ if (topic == SyncedTabs.TOPIC_TABS_CHANGED) {
+ this._showSyncedTabs();
+ }
+ }
+
+ createSyncedTabs() {
+ if (SyncedTabs.isConfiguredToSyncTabs) {
+ if (SyncedTabs.hasSyncedThisSession) {
+ this.deck.selectedIndex =
+ SyncedTabsPanelList.sRemoteTabsDeckIndices.DECKINDEX_TABS;
+ } else {
+ // Sync hasn't synced tabs yet, so show the "fetching" panel.
+ this.deck.selectedIndex =
+ SyncedTabsPanelList.sRemoteTabsDeckIndices.DECKINDEX_FETCHING;
+ }
+ // force a background sync.
+ SyncedTabs.syncTabs().catch(ex => {
+ console.error(ex);
+ });
+ this.deck.toggleAttribute("syncingtabs", true);
+ // show the current list - it will be updated by our observer.
+ this._showSyncedTabs();
+ if (this.separator) {
+ this.separator.hidden = false;
+ }
+ } else {
+ // not configured to sync tabs, so no point updating the list.
+ this.deck.selectedIndex =
+ SyncedTabsPanelList.sRemoteTabsDeckIndices.DECKINDEX_TABSDISABLED;
+ this.deck.toggleAttribute("syncingtabs", false);
+ if (this.separator) {
+ this.separator.hidden = true;
+ }
+ }
+ }
+
+ // Update the synced tab list after any existing in-flight updates are complete.
+ _showSyncedTabs(paginationInfo) {
+ this._showSyncedTabsPromise = this._showSyncedTabsPromise.then(
+ () => {
+ return this.__showSyncedTabs(paginationInfo);
+ },
+ e => {
+ console.error(e);
+ }
+ );
+ }
+
+ // Return a new promise to update the tab list.
+ __showSyncedTabs(paginationInfo) {
+ if (!this.tabsList) {
+ // Closed between the previous `this._showSyncedTabsPromise`
+ // resolving and now.
+ return undefined;
+ }
+ return SyncedTabs.getTabClients()
+ .then(clients => {
+ let noTabs = !UIState.get().syncEnabled || !clients.length;
+ this.deck.toggleAttribute("syncingtabs", !noTabs);
+ if (this.separator) {
+ this.separator.hidden = noTabs;
+ }
+
+ // The view may have been hidden while the promise was resolving.
+ if (!this.tabsList) {
+ return;
+ }
+ if (clients.length === 0 && !SyncedTabs.hasSyncedThisSession) {
+ // the "fetching tabs" deck is being shown - let's leave it there.
+ // When that first sync completes we'll be notified and update.
+ return;
+ }
+
+ if (clients.length === 0) {
+ this.deck.selectedIndex =
+ SyncedTabsPanelList.sRemoteTabsDeckIndices.DECKINDEX_NOCLIENTS;
+ return;
+ }
+ this.deck.selectedIndex =
+ SyncedTabsPanelList.sRemoteTabsDeckIndices.DECKINDEX_TABS;
+ this._clearSyncedTabList();
+ SyncedTabs.sortTabClientsByLastUsed(clients);
+ let fragment = document.createDocumentFragment();
+
+ let clientNumber = 0;
+ for (let client of clients) {
+ // add a menu separator for all clients other than the first.
+ if (fragment.lastElementChild) {
+ let separator = document.createXULElement("toolbarseparator");
+ fragment.appendChild(separator);
+ }
+ // We add the client's elements to a container, and indicate which
+ // element labels it.
+ let labelId = `synced-tabs-client-${clientNumber++}`;
+ let container = document.createXULElement("vbox");
+ container.classList.add("PanelUI-remotetabs-clientcontainer");
+ container.setAttribute("role", "group");
+ container.setAttribute("aria-labelledby", labelId);
+ if (paginationInfo && paginationInfo.clientId == client.id) {
+ this._appendSyncClient(
+ client,
+ container,
+ labelId,
+ paginationInfo.maxTabs
+ );
+ } else {
+ this._appendSyncClient(client, container, labelId);
+ }
+ fragment.appendChild(container);
+ }
+ this.tabsList.appendChild(fragment);
+ })
+ .catch(err => {
+ console.error(err);
+ })
+ .then(() => {
+ // an observer for tests.
+ Services.obs.notifyObservers(
+ null,
+ "synced-tabs-menu:test:tabs-updated"
+ );
+ });
+ }
+
+ _clearSyncedTabList() {
+ let list = this.tabsList;
+ while (list.lastChild) {
+ list.lastChild.remove();
+ }
+ }
+
+ _createNoSyncedTabsElement(messageAttr, appendTo = null) {
+ if (!appendTo) {
+ appendTo = this.tabsList;
+ }
+
+ let messageLabel = document.createXULElement("label");
+ document.l10n.setAttributes(
+ messageLabel,
+ this.tabsList.getAttribute(messageAttr)
+ );
+ appendTo.appendChild(messageLabel);
+ return messageLabel;
+ }
+
+ _appendSyncClient(
+ client,
+ container,
+ labelId,
+ maxTabs = SyncedTabsPanelList.sRemoteTabsPerPage
+ ) {
+ // Create the element for the remote client.
+ let clientItem = document.createXULElement("label");
+ clientItem.setAttribute("id", labelId);
+ clientItem.setAttribute("itemtype", "client");
+ clientItem.setAttribute(
+ "tooltiptext",
+ gSync.fluentStrings.formatValueSync("appmenu-fxa-last-sync", {
+ time: gSync.formatLastSyncDate(new Date(client.lastModified)),
+ })
+ );
+ clientItem.textContent = client.name;
+
+ container.appendChild(clientItem);
+
+ if (!client.tabs.length) {
+ let label = this._createNoSyncedTabsElement(
+ "notabsforclientlabel",
+ container
+ );
+ label.setAttribute("class", "PanelUI-remotetabs-notabsforclient-label");
+ } else {
+ // If this page will display all tabs, show no additional buttons.
+ // Otherwise, show a "Show More" button
+ let hasNextPage = client.tabs.length > maxTabs;
+ let nextPageIsLastPage =
+ hasNextPage &&
+ maxTabs + SyncedTabsPanelList.sRemoteTabsPerPage >= client.tabs.length;
+ if (nextPageIsLastPage) {
+ // When the user clicks "Show More", try to have at least sRemoteTabsNextPageMinTabs more tabs
+ // to display in order to avoid user frustration
+ maxTabs = Math.min(
+ client.tabs.length - SyncedTabsPanelList.sRemoteTabsNextPageMinTabs,
+ maxTabs
+ );
+ }
+ if (hasNextPage) {
+ client.tabs = client.tabs.slice(0, maxTabs);
+ }
+ for (let [index, tab] of client.tabs.entries()) {
+ let tabEnt = this._createSyncedTabElement(tab, index);
+ container.appendChild(tabEnt);
+ }
+ if (hasNextPage) {
+ let showAllEnt = this._createShowMoreSyncedTabsElement(client.id);
+ container.appendChild(showAllEnt);
+ }
+ }
+ }
+
+ _createSyncedTabElement(tabInfo, index) {
+ let item = document.createXULElement("toolbarbutton");
+ let tooltipText = (tabInfo.title ? tabInfo.title + "\n" : "") + tabInfo.url;
+ item.setAttribute("itemtype", "tab");
+ item.setAttribute("class", "subviewbutton");
+ item.setAttribute("targetURI", tabInfo.url);
+ item.setAttribute(
+ "label",
+ tabInfo.title != "" ? tabInfo.title : tabInfo.url
+ );
+ if (tabInfo.icon) {
+ item.setAttribute("image", tabInfo.icon);
+ }
+ item.setAttribute("tooltiptext", tooltipText);
+ // We need to use "click" instead of "command" here so openUILink
+ // respects different buttons (eg, to open in a new tab).
+ item.addEventListener("click", e => {
+ // We want to differentiate between when the fxa panel is within the app menu/hamburger bar
+ let object = "fxa_avatar_menu";
+ const appMenuPanel = document.getElementById("appMenu-popup");
+ if (appMenuPanel.contains(e.currentTarget)) {
+ object = "fxa_app_menu";
+ }
+ SyncedTabs.recordSyncedTabsTelemetry(object, "click", {
+ tab_pos: index.toString(),
+ });
+ document.defaultView.openUILink(tabInfo.url, e, {
+ triggeringPrincipal: Services.scriptSecurityManager.createNullPrincipal(
+ {}
+ ),
+ });
+ if (document.defaultView.whereToOpenLink(e) != "current") {
+ e.preventDefault();
+ e.stopPropagation();
+ } else {
+ CustomizableUI.hidePanelForNode(item);
+ }
+ });
+ return item;
+ }
+
+ _createShowMoreSyncedTabsElement(clientId) {
+ let showCount = Infinity;
+
+ let showMoreItem = document.createXULElement("toolbarbutton");
+ showMoreItem.setAttribute("itemtype", "showmorebutton");
+ showMoreItem.setAttribute("closemenu", "none");
+ showMoreItem.classList.add(
+ "subviewbutton",
+ "subviewbutton-nav",
+ "subviewbutton-nav-down"
+ );
+ document.l10n.setAttributes(showMoreItem, "appmenu-remote-tabs-showmore");
+
+ showMoreItem.addEventListener("click", e => {
+ e.preventDefault();
+ e.stopPropagation();
+ this._showSyncedTabs({ clientId, maxTabs: showCount });
+ });
+ return showMoreItem;
+ }
+
+ destroy() {
+ Services.obs.removeObserver(this, SyncedTabs.TOPIC_TABS_CHANGED);
+ this.tabsList = null;
+ this.deck = null;
+ this.separator = null;
+ }
+};
+
+var gSync = {
+ _initialized: false,
+ _isCurrentlySyncing: false,
+ // The last sync start time. Used to calculate the leftover animation time
+ // once syncing completes (bug 1239042).
+ _syncStartTime: 0,
+ _syncAnimationTimer: 0,
+ _obs: ["weave:engine:sync:finish", "quit-application", UIState.ON_UPDATE],
+
+ get log() {
+ if (!this._log) {
+ const { Log } = ChromeUtils.importESModule(
+ "resource://gre/modules/Log.sys.mjs"
+ );
+ let syncLog = Log.repository.getLogger("Sync.Browser");
+ syncLog.manageLevelFromPref("services.sync.log.logger.browser");
+ this._log = syncLog;
+ }
+ return this._log;
+ },
+
+ get fluentStrings() {
+ delete this.fluentStrings;
+ return (this.fluentStrings = new Localization(
+ [
+ "branding/brand.ftl",
+ "browser/accounts.ftl",
+ "browser/appmenu.ftl",
+ "browser/sync.ftl",
+ "toolkit/branding/accounts.ftl",
+ ],
+ true
+ ));
+ },
+
+ // Returns true if FxA is configured, but the send tab targets list isn't
+ // ready yet.
+ get sendTabConfiguredAndLoading() {
+ return (
+ UIState.get().status == UIState.STATUS_SIGNED_IN &&
+ !fxAccounts.device.recentDeviceList
+ );
+ },
+
+ get isSignedIn() {
+ return UIState.get().status == UIState.STATUS_SIGNED_IN;
+ },
+
+ shouldHideSendContextMenuItems(enabled) {
+ const state = UIState.get();
+ // Only show the "Send..." context menu items when sending would be possible
+ if (
+ enabled &&
+ state.status == UIState.STATUS_SIGNED_IN &&
+ state.syncEnabled &&
+ this.getSendTabTargets().length
+ ) {
+ return false;
+ }
+ return true;
+ },
+
+ getSendTabTargets() {
+ const targets = [];
+ if (
+ UIState.get().status != UIState.STATUS_SIGNED_IN ||
+ !fxAccounts.device.recentDeviceList
+ ) {
+ return targets;
+ }
+ for (let d of fxAccounts.device.recentDeviceList) {
+ if (d.isCurrentDevice) {
+ continue;
+ }
+
+ if (fxAccounts.commands.sendTab.isDeviceCompatible(d)) {
+ targets.push(d);
+ }
+ }
+ return targets.sort((a, b) => b.lastAccessTime - a.lastAccessTime);
+ },
+
+ _definePrefGetters() {
+ XPCOMUtils.defineLazyPreferenceGetter(
+ this,
+ "FXA_ENABLED",
+ "identity.fxaccounts.enabled"
+ );
+ },
+
+ maybeUpdateUIState() {
+ // Update the UI.
+ if (UIState.isReady()) {
+ const state = UIState.get();
+ // If we are not configured, the UI is already in the right state when
+ // we open the window. We can avoid a repaint.
+ if (state.status != UIState.STATUS_NOT_CONFIGURED) {
+ this.updateAllUI(state);
+ }
+ }
+ },
+
+ init() {
+ if (this._initialized) {
+ return;
+ }
+
+ this._definePrefGetters();
+
+ if (!this.FXA_ENABLED) {
+ this.onFxaDisabled();
+ return;
+ }
+
+ MozXULElement.insertFTLIfNeeded("browser/sync.ftl");
+
+ // Label for the sync buttons.
+ const appMenuLabel = PanelMultiView.getViewNode(
+ document,
+ "appMenu-fxa-label2"
+ );
+ if (!appMenuLabel) {
+ // We are in a window without our elements - just abort now, without
+ // setting this._initialized, so we don't attempt to remove observers.
+ return;
+ }
+ // We start with every menuitem hidden (except for the "setup sync" state),
+ // so that we don't need to init the sync UI on windows like pageInfo.xhtml
+ // (see bug 1384856).
+ // maybeUpdateUIState() also optimizes for this - if we should be in the
+ // "setup sync" state, that function assumes we are already in it and
+ // doesn't re-initialize the UI elements.
+ document.getElementById("sync-setup").hidden = false;
+ PanelMultiView.getViewNode(
+ document,
+ "PanelUI-remotetabs-setupsync"
+ ).hidden = false;
+
+ const appMenuHeaderTitle = PanelMultiView.getViewNode(
+ document,
+ "appMenu-header-title"
+ );
+ const appMenuHeaderDescription = PanelMultiView.getViewNode(
+ document,
+ "appMenu-header-description"
+ );
+ const appMenuHeaderText = PanelMultiView.getViewNode(
+ document,
+ "appMenu-fxa-text"
+ );
+ appMenuHeaderTitle.hidden = true;
+ // We must initialize the label attribute here instead of the markup
+ // due to a timing error. The fluent label attribute was being applied
+ // after we had updated appMenuLabel and thus displayed an incorrect
+ // label for signed in users.
+ const [headerDesc, headerText] = this.fluentStrings.formatValuesSync([
+ "appmenu-fxa-signed-in-label",
+ "appmenu-fxa-sync-and-save-data2",
+ ]);
+ appMenuHeaderDescription.value = headerDesc;
+ appMenuHeaderText.textContent = headerText;
+
+ for (let topic of this._obs) {
+ Services.obs.addObserver(this, topic, true);
+ }
+
+ this.maybeUpdateUIState();
+
+ EnsureFxAccountsWebChannel();
+
+ let fxaPanelView = PanelMultiView.getViewNode(document, "PanelUI-fxa");
+ fxaPanelView.addEventListener("ViewShowing", this);
+ fxaPanelView.addEventListener("ViewHiding", this);
+
+ this._initialized = true;
+ },
+
+ uninit() {
+ if (!this._initialized) {
+ return;
+ }
+
+ for (let topic of this._obs) {
+ Services.obs.removeObserver(this, topic);
+ }
+
+ this._initialized = false;
+ },
+
+ handleEvent(event) {
+ switch (event.type) {
+ case "ViewShowing": {
+ this.onFxAPanelViewShowing(event.target);
+ break;
+ }
+ case "ViewHiding": {
+ this.onFxAPanelViewHiding(event.target);
+ }
+ }
+ },
+
+ onFxAPanelViewShowing(panelview) {
+ let syncNowBtn = panelview.querySelector(".syncnow-label");
+ let l10nId = syncNowBtn.getAttribute(
+ this._isCurrentlySyncing
+ ? "syncing-data-l10n-id"
+ : "sync-now-data-l10n-id"
+ );
+ syncNowBtn.setAttribute("data-l10n-id", l10nId);
+
+ // This needs to exist because if the user is signed in
+ // but the user disabled or disconnected sync we should not show the button
+ const syncPrefsButtonEl = PanelMultiView.getViewNode(
+ document,
+ "PanelUI-fxa-menu-sync-prefs-button"
+ );
+ syncPrefsButtonEl.hidden = !UIState.get().syncEnabled;
+
+ panelview.syncedTabsPanelList = new SyncedTabsPanelList(
+ panelview,
+ PanelMultiView.getViewNode(document, "PanelUI-fxa-remotetabs-deck"),
+ PanelMultiView.getViewNode(document, "PanelUI-fxa-remotetabs-tabslist"),
+ PanelMultiView.getViewNode(document, "PanelUI-remote-tabs-separator")
+ );
+ },
+
+ onFxAPanelViewHiding(panelview) {
+ panelview.syncedTabsPanelList.destroy();
+ panelview.syncedTabsPanelList = null;
+ },
+
+ observe(subject, topic, data) {
+ if (!this._initialized) {
+ console.error("browser-sync observer called after unload: ", topic);
+ return;
+ }
+ switch (topic) {
+ case UIState.ON_UPDATE:
+ const state = UIState.get();
+ this.updateAllUI(state);
+ break;
+ case "quit-application":
+ // Stop the animation timer on shutdown, since we can't update the UI
+ // after this.
+ clearTimeout(this._syncAnimationTimer);
+ break;
+ case "weave:engine:sync:finish":
+ if (data != "clients") {
+ return;
+ }
+ this.onClientsSynced();
+ this.updateFxAPanel(UIState.get());
+ break;
+ }
+ },
+
+ updateAllUI(state) {
+ this.updatePanelPopup(state);
+ this.updateState(state);
+ this.updateSyncButtonsTooltip(state);
+ this.updateSyncStatus(state);
+ this.updateFxAPanel(state);
+ // Ensure we have something in the device list in the background.
+ this.ensureFxaDevices();
+ },
+
+ // Ensure we have *something* in `fxAccounts.device.recentDeviceList` as some
+ // of our UI logic depends on it not being null. When FxA is notified of a
+ // device change it will auto refresh `recentDeviceList`, and all UI which
+ // shows the device list will start with `recentDeviceList`, but should also
+ // force a refresh, both of which should mean in the worst-case, the UI is up
+ // to date after a very short delay.
+ async ensureFxaDevices(options) {
+ if (UIState.get().status != UIState.STATUS_SIGNED_IN) {
+ console.info("Skipping device list refresh; not signed in");
+ return;
+ }
+ if (!fxAccounts.device.recentDeviceList) {
+ if (await this.refreshFxaDevices()) {
+ // Assuming we made the call successfully it should be impossible to end
+ // up with a falsey recentDeviceList, so make noise if that's false.
+ if (!fxAccounts.device.recentDeviceList) {
+ console.warn("Refreshing device list didn't find any devices.");
+ }
+ }
+ }
+ },
+
+ // Force a refresh of the fxa device list. Note that while it's theoretically
+ // OK to call `fxAccounts.device.refreshDeviceList` multiple times concurrently
+ // and regularly, this call tells it to avoid those protections, so will always
+ // hit the FxA servers - therefore, you should be very careful how often you
+ // call this.
+ // Returns Promise<bool> to indicate whether a refresh was actually done.
+ async refreshFxaDevices() {
+ if (UIState.get().status != UIState.STATUS_SIGNED_IN) {
+ console.info("Skipping device list refresh; not signed in");
+ return false;
+ }
+ try {
+ // Do the actual refresh telling it to avoid the "flooding" protections.
+ await fxAccounts.device.refreshDeviceList({ ignoreCached: true });
+ return true;
+ } catch (e) {
+ this.log.error("Refreshing device list failed.", e);
+ return false;
+ }
+ },
+
+ updateSendToDeviceTitle() {
+ const tabCount = gBrowser.selectedTab.multiselected
+ ? gBrowser.selectedTabs.length
+ : 1;
+ document.l10n.setArgs(
+ PanelMultiView.getViewNode(document, "PanelUI-fxa-menu-sendtab-button"),
+ { tabCount }
+ );
+ },
+
+ showSendToDeviceView(anchor) {
+ PanelUI.showSubView("PanelUI-sendTabToDevice", anchor);
+ let panelViewNode = document.getElementById("PanelUI-sendTabToDevice");
+ this._populateSendTabToDevicesView(panelViewNode);
+ },
+
+ showSendToDeviceViewFromFxaMenu(anchor) {
+ const { status } = UIState.get();
+ if (status === UIState.STATUS_NOT_CONFIGURED) {
+ PanelUI.showSubView("PanelUI-fxa-menu-sendtab-not-configured", anchor);
+ return;
+ }
+
+ const targets = this.sendTabConfiguredAndLoading
+ ? []
+ : this.getSendTabTargets();
+ if (!targets.length) {
+ PanelUI.showSubView("PanelUI-fxa-menu-sendtab-no-devices", anchor);
+ return;
+ }
+
+ this.showSendToDeviceView(anchor);
+ this.emitFxaToolbarTelemetry("send_tab", anchor);
+ },
+
+ showRemoteTabsFromFxaMenu(panel) {
+ PanelUI.showSubView("PanelUI-remotetabs", panel);
+ this.emitFxaToolbarTelemetry("sync_tabs", panel);
+ },
+
+ showSidebarFromFxaMenu(panel) {
+ SidebarUI.toggle("viewTabsSidebar");
+ this.emitFxaToolbarTelemetry("sync_tabs_sidebar", panel);
+ },
+
+ _populateSendTabToDevicesView(panelViewNode, reloadDevices = true) {
+ let bodyNode = panelViewNode.querySelector(".panel-subview-body");
+ let panelNode = panelViewNode.closest("panel");
+ let browser = gBrowser.selectedBrowser;
+ let uri = browser.currentURI;
+ let title = browser.contentTitle;
+ let multiselected = gBrowser.selectedTab.multiselected;
+
+ // This is on top because it also clears the device list between state
+ // changes.
+ this.populateSendTabToDevicesMenu(
+ bodyNode,
+ uri,
+ title,
+ multiselected,
+ (clientId, name, clientType, lastModified) => {
+ if (!name) {
+ return document.createXULElement("toolbarseparator");
+ }
+ let item = document.createXULElement("toolbarbutton");
+ item.setAttribute("wrap", true);
+ item.setAttribute("align", "start");
+ item.classList.add("sendToDevice-device", "subviewbutton");
+ if (clientId) {
+ item.classList.add("subviewbutton-iconic");
+ if (lastModified) {
+ let lastSyncDate = gSync.formatLastSyncDate(lastModified);
+ if (lastSyncDate) {
+ item.setAttribute(
+ "tooltiptext",
+ this.fluentStrings.formatValueSync("appmenu-fxa-last-sync", {
+ time: lastSyncDate,
+ })
+ );
+ }
+ }
+ }
+
+ item.addEventListener("command", event => {
+ if (panelNode) {
+ PanelMultiView.hidePopup(panelNode);
+ }
+ });
+ return item;
+ },
+ true
+ );
+
+ bodyNode.removeAttribute("state");
+ // If the app just started, we won't have fetched the device list yet. Sync
+ // does this automatically ~10 sec after startup, but there's no trigger for
+ // this if we're signed in to FxA, but not Sync.
+ if (gSync.sendTabConfiguredAndLoading) {
+ bodyNode.setAttribute("state", "notready");
+ }
+ if (reloadDevices) {
+ // We will only pick up new Fennec clients if we sync the clients engine,
+ // but all other send-tab targets can be identified purely from the fxa
+ // device list. Syncing the clients engine doesn't force a refresh of the
+ // fxa list, and it seems overkill to force *both* a clients engine sync
+ // and an fxa device list refresh, especially given (a) the clients engine
+ // will sync by itself every 10 minutes and (b) Fennec is (at time of
+ // writing) about to be replaced by Fenix.
+ // So we suck up the fact that new Fennec clients may not appear for 10
+ // minutes and don't bother syncing the clients engine.
+
+ // Force a refresh of the fxa device list in case the user connected a new
+ // device, and is waiting for it to show up.
+ this.refreshFxaDevices().then(_ => {
+ if (!window.closed) {
+ this._populateSendTabToDevicesView(panelViewNode, false);
+ }
+ });
+ }
+ },
+
+ toggleAccountPanel(
+ anchor = document.getElementById("fxa-toolbar-menu-button"),
+ aEvent
+ ) {
+ // Don't show the panel if the window is in customization mode.
+ if (document.documentElement.hasAttribute("customizing")) {
+ return;
+ }
+
+ if (
+ (aEvent.type == "mousedown" && aEvent.button != 0) ||
+ (aEvent.type == "keypress" &&
+ aEvent.charCode != KeyEvent.DOM_VK_SPACE &&
+ aEvent.keyCode != KeyEvent.DOM_VK_RETURN)
+ ) {
+ return;
+ }
+
+ // We read the state that's been set on the root node, since that makes
+ // it easier to test the various front-end states without having to actually
+ // have UIState know about it.
+ let fxaStatus = document.documentElement.getAttribute("fxastatus");
+
+ if (fxaStatus == "not_configured") {
+ let extraParams = {};
+ let fxaButtonVisibilityExperiment =
+ ExperimentAPI.getExperimentMetaData({
+ featureId: "fxaButtonVisibility",
+ }) ??
+ ExperimentAPI.getRolloutMetaData({
+ featureId: "fxaButtonVisibility",
+ });
+ if (fxaButtonVisibilityExperiment) {
+ extraParams = {
+ entrypoint_experiment: fxaButtonVisibilityExperiment.slug,
+ entrypoint_variation: fxaButtonVisibilityExperiment.branch.slug,
+ };
+ }
+
+ let panel =
+ anchor.id == "appMenu-fxa-label2"
+ ? PanelMultiView.getViewNode(document, "PanelUI-fxa")
+ : undefined;
+ this.openFxAEmailFirstPageFromFxaMenu(panel, extraParams);
+ PanelUI.hide();
+ return;
+ }
+
+ if (!gFxaToolbarAccessed) {
+ Services.prefs.setBoolPref("identity.fxaccounts.toolbar.accessed", true);
+ }
+
+ this.enableSendTabIfValidTab();
+
+ if (!this.getSendTabTargets().length) {
+ PanelMultiView.getViewNode(
+ document,
+ "PanelUI-fxa-menu-sendtab-button"
+ ).hidden = true;
+ }
+
+ if (anchor.getAttribute("open") == "true") {
+ PanelUI.hide();
+ } else {
+ this.emitFxaToolbarTelemetry("toolbar_icon", anchor);
+ PanelUI.showSubView("PanelUI-fxa", anchor, aEvent);
+ }
+ },
+
+ updateFxAPanel(state = {}) {
+ const mainWindowEl = document.documentElement;
+
+ // The Firefox Account toolbar currently handles 3 different states for
+ // users. The default `not_configured` state shows an empty avatar, `unverified`
+ // state shows an avatar with an email icon, `login-failed` state shows an avatar
+ // with a danger icon and the `verified` state will show the users
+ // custom profile image or a filled avatar.
+ let stateValue = "not_configured";
+
+ const menuHeaderTitleEl = PanelMultiView.getViewNode(
+ document,
+ "fxa-menu-header-title"
+ );
+ const menuHeaderDescriptionEl = PanelMultiView.getViewNode(
+ document,
+ "fxa-menu-header-description"
+ );
+
+ const cadButtonEl = PanelMultiView.getViewNode(
+ document,
+ "PanelUI-fxa-menu-connect-device-button"
+ );
+
+ const syncSetupButtonEl = PanelMultiView.getViewNode(
+ document,
+ "PanelUI-fxa-menu-setup-sync-button"
+ );
+
+ const syncNowButtonEl = PanelMultiView.getViewNode(
+ document,
+ "PanelUI-fxa-menu-syncnow-button"
+ );
+
+ const fxaMenuAccountButtonEl = PanelMultiView.getViewNode(
+ document,
+ "fxa-manage-account-button"
+ );
+
+ cadButtonEl.setAttribute("disabled", true);
+ syncNowButtonEl.hidden = true;
+ fxaMenuAccountButtonEl.classList.remove("subviewbutton-nav");
+ fxaMenuAccountButtonEl.removeAttribute("closemenu");
+ syncSetupButtonEl.removeAttribute("hidden");
+
+ let headerTitleL10nId = "appmenuitem-fxa-sign-in";
+ let headerDescription;
+ if (state.status === UIState.STATUS_NOT_CONFIGURED) {
+ mainWindowEl.style.removeProperty("--avatar-image-url");
+ headerDescription = this.fluentStrings.formatValueSync(
+ "appmenu-fxa-signed-in-label"
+ );
+ } else if (state.status === UIState.STATUS_LOGIN_FAILED) {
+ stateValue = "login-failed";
+ headerTitleL10nId = "account-disconnected2";
+ headerDescription = state.email;
+ mainWindowEl.style.removeProperty("--avatar-image-url");
+ } else if (state.status === UIState.STATUS_NOT_VERIFIED) {
+ stateValue = "unverified";
+ headerTitleL10nId = "account-finish-account-setup";
+ headerDescription = state.email;
+ } else if (state.status === UIState.STATUS_SIGNED_IN) {
+ stateValue = "signedin";
+ if (state.avatarURL && !state.avatarIsDefault) {
+ // The user has specified a custom avatar, attempt to load the image on all the menu buttons.
+ const bgImage = `url("${state.avatarURL}")`;
+ let img = new Image();
+ img.onload = () => {
+ // If the image has successfully loaded, update the menu buttons else
+ // we will use the default avatar image.
+ mainWindowEl.style.setProperty("--avatar-image-url", bgImage);
+ };
+ img.onerror = () => {
+ // If the image failed to load, remove the property and default
+ // to standard avatar.
+ mainWindowEl.style.removeProperty("--avatar-image-url");
+ };
+ img.src = state.avatarURL;
+ } else {
+ mainWindowEl.style.removeProperty("--avatar-image-url");
+ }
+
+ cadButtonEl.removeAttribute("disabled");
+
+ if (state.syncEnabled) {
+ syncNowButtonEl.removeAttribute("hidden");
+ syncSetupButtonEl.hidden = true;
+ }
+
+ headerTitleL10nId = "appmenuitem-fxa-manage-account";
+ headerDescription = state.email;
+ } else {
+ headerDescription = this.fluentStrings.formatValueSync(
+ "fxa-menu-turn-on-sync-default"
+ );
+ }
+ mainWindowEl.setAttribute("fxastatus", stateValue);
+
+ menuHeaderTitleEl.value =
+ this.fluentStrings.formatValueSync(headerTitleL10nId);
+ menuHeaderDescriptionEl.value = headerDescription;
+ // We remove the data-l10n-id attribute here to prevent the node's value
+ // attribute from being overwritten by Fluent when the panel is moved
+ // around in the DOM.
+ menuHeaderTitleEl.removeAttribute("data-l10n-id");
+ menuHeaderDescriptionEl.removeAttribute("data-l10n-id");
+ },
+
+ enableSendTabIfValidTab() {
+ // All tabs selected must be sendable for the Send Tab button to be enabled
+ // on the FxA menu.
+ let canSendAllURIs = gBrowser.selectedTabs.every(
+ t => !!BrowserUtils.getShareableURL(t.linkedBrowser.currentURI)
+ );
+
+ PanelMultiView.getViewNode(
+ document,
+ "PanelUI-fxa-menu-sendtab-button"
+ ).hidden = !canSendAllURIs;
+ },
+
+ emitFxaToolbarTelemetry(type, panel) {
+ if (UIState.isReady() && panel) {
+ const state = UIState.get();
+ const hasAvatar = state.avatarURL && !state.avatarIsDefault;
+ let extraOptions = {
+ fxa_status: state.status,
+ fxa_avatar: hasAvatar ? "true" : "false",
+ };
+
+ // When the fxa avatar panel is within the Firefox app menu,
+ // we emit different telemetry.
+ let eventName = "fxa_avatar_menu";
+ if (this.isPanelInsideAppMenu(panel)) {
+ eventName = "fxa_app_menu";
+ }
+
+ Services.telemetry.recordEvent(
+ eventName,
+ "click",
+ type,
+ null,
+ extraOptions
+ );
+ }
+ },
+
+ isPanelInsideAppMenu(panel = undefined) {
+ const appMenuPanel = document.getElementById("appMenu-popup");
+ if (panel && appMenuPanel.contains(panel)) {
+ return true;
+ }
+ return false;
+ },
+
+ updatePanelPopup({ email, status }) {
+ const appMenuStatus = PanelMultiView.getViewNode(
+ document,
+ "appMenu-fxa-status2"
+ );
+ const appMenuLabel = PanelMultiView.getViewNode(
+ document,
+ "appMenu-fxa-label2"
+ );
+ const appMenuHeaderText = PanelMultiView.getViewNode(
+ document,
+ "appMenu-fxa-text"
+ );
+ const appMenuHeaderTitle = PanelMultiView.getViewNode(
+ document,
+ "appMenu-header-title"
+ );
+ const appMenuHeaderDescription = PanelMultiView.getViewNode(
+ document,
+ "appMenu-header-description"
+ );
+ const fxaPanelView = PanelMultiView.getViewNode(document, "PanelUI-fxa");
+
+ let defaultLabel = this.fluentStrings.formatValueSync(
+ "appmenu-fxa-signed-in-label"
+ );
+ // Reset the status bar to its original state.
+ appMenuLabel.setAttribute("label", defaultLabel);
+ appMenuLabel.removeAttribute("aria-labelledby");
+ appMenuStatus.removeAttribute("fxastatus");
+
+ if (status == UIState.STATUS_NOT_CONFIGURED) {
+ appMenuHeaderText.hidden = false;
+ appMenuStatus.classList.add("toolbaritem-combined-buttons");
+ appMenuLabel.classList.remove("subviewbutton-nav");
+ appMenuHeaderTitle.hidden = true;
+ appMenuHeaderDescription.value = defaultLabel;
+ return;
+ }
+ appMenuLabel.classList.remove("subviewbutton-nav");
+
+ appMenuHeaderText.hidden = true;
+ appMenuStatus.classList.remove("toolbaritem-combined-buttons");
+
+ // At this point we consider sync to be configured (but still can be in an error state).
+ if (status == UIState.STATUS_LOGIN_FAILED) {
+ const [tooltipDescription, errorLabel] =
+ this.fluentStrings.formatValuesSync([
+ { id: "account-reconnect", args: { email } },
+ { id: "account-disconnected2" },
+ ]);
+ appMenuStatus.setAttribute("fxastatus", "login-failed");
+ appMenuStatus.setAttribute("tooltiptext", tooltipDescription);
+ appMenuLabel.classList.add("subviewbutton-nav");
+ appMenuHeaderTitle.hidden = false;
+ appMenuHeaderTitle.value = errorLabel;
+ appMenuHeaderDescription.value = email;
+
+ appMenuLabel.removeAttribute("label");
+ appMenuLabel.setAttribute(
+ "aria-labelledby",
+ `${appMenuHeaderTitle.id},${appMenuHeaderDescription.id}`
+ );
+ return;
+ } else if (status == UIState.STATUS_NOT_VERIFIED) {
+ const [tooltipDescription, unverifiedLabel] =
+ this.fluentStrings.formatValuesSync([
+ { id: "account-verify", args: { email } },
+ { id: "account-finish-account-setup" },
+ ]);
+ appMenuStatus.setAttribute("fxastatus", "unverified");
+ appMenuStatus.setAttribute("tooltiptext", tooltipDescription);
+ appMenuLabel.classList.add("subviewbutton-nav");
+ appMenuHeaderTitle.hidden = false;
+ appMenuHeaderTitle.value = unverifiedLabel;
+ appMenuHeaderDescription.value = email;
+
+ appMenuLabel.removeAttribute("label");
+ appMenuLabel.setAttribute(
+ "aria-labelledby",
+ `${appMenuHeaderTitle.id},${appMenuHeaderDescription.id}`
+ );
+ return;
+ }
+
+ // At this point we consider sync to be logged-in.
+ appMenuHeaderTitle.hidden = true;
+ appMenuHeaderDescription.value = email;
+ appMenuStatus.setAttribute("fxastatus", "signedin");
+ appMenuLabel.setAttribute("label", email);
+ appMenuLabel.classList.add("subviewbutton-nav");
+ fxaPanelView.setAttribute(
+ "title",
+ this.fluentStrings.formatValueSync("appmenu-fxa-header2")
+ );
+ appMenuStatus.removeAttribute("tooltiptext");
+ },
+
+ updateState(state) {
+ for (let [shown, menuId, boxId] of [
+ [
+ state.status == UIState.STATUS_NOT_CONFIGURED,
+ "sync-setup",
+ "PanelUI-remotetabs-setupsync",
+ ],
+ [
+ state.status == UIState.STATUS_SIGNED_IN && !state.syncEnabled,
+ "sync-enable",
+ "PanelUI-remotetabs-syncdisabled",
+ ],
+ [
+ state.status == UIState.STATUS_LOGIN_FAILED,
+ "sync-reauthitem",
+ "PanelUI-remotetabs-reauthsync",
+ ],
+ [
+ state.status == UIState.STATUS_NOT_VERIFIED,
+ "sync-unverifieditem",
+ "PanelUI-remotetabs-unverified",
+ ],
+ [
+ state.status == UIState.STATUS_SIGNED_IN && state.syncEnabled,
+ "sync-syncnowitem",
+ "PanelUI-remotetabs-main",
+ ],
+ ]) {
+ document.getElementById(menuId).hidden = PanelMultiView.getViewNode(
+ document,
+ boxId
+ ).hidden = !shown;
+ }
+ },
+
+ updateSyncStatus(state) {
+ let syncNow =
+ document.querySelector(".syncNowBtn") ||
+ document
+ .getElementById("appMenu-viewCache")
+ .content.querySelector(".syncNowBtn");
+ const syncingUI = syncNow.getAttribute("syncstatus") == "active";
+ if (state.syncing != syncingUI) {
+ // Do we need to update the UI?
+ state.syncing ? this.onActivityStart() : this.onActivityStop();
+ }
+ },
+
+ async openSignInAgainPage(entryPoint) {
+ if (!(await FxAccounts.canConnectAccount())) {
+ return;
+ }
+ const url = await FxAccounts.config.promiseForceSigninURI(entryPoint);
+ switchToTabHavingURI(url, true, {
+ replaceQueryString: true,
+ triggeringPrincipal: Services.scriptSecurityManager.getSystemPrincipal(),
+ });
+ },
+
+ async openDevicesManagementPage(entryPoint) {
+ let url = await FxAccounts.config.promiseManageDevicesURI(entryPoint);
+ switchToTabHavingURI(url, true, {
+ replaceQueryString: true,
+ triggeringPrincipal: Services.scriptSecurityManager.getSystemPrincipal(),
+ });
+ },
+
+ async openConnectAnotherDevice(entryPoint) {
+ const url = await FxAccounts.config.promiseConnectDeviceURI(entryPoint);
+ openTrustedLinkIn(url, "tab");
+ },
+
+ async openConnectAnotherDeviceFromFxaMenu(panel = undefined) {
+ this.emitFxaToolbarTelemetry("cad", panel);
+ let entryPoint = "fxa_discoverability_native";
+ if (this.isPanelInsideAppMenu(panel)) {
+ entryPoint = "fxa_app_menu";
+ }
+ this.openConnectAnotherDevice(entryPoint);
+ },
+
+ openSendToDevicePromo() {
+ const url = Services.urlFormatter.formatURLPref(
+ "identity.sendtabpromo.url"
+ );
+ switchToTabHavingURI(url, true, { replaceQueryString: true });
+ },
+
+ async clickFxAMenuHeaderButton(panel = undefined) {
+ // Depending on the current logged in state of a user,
+ // clicking the FxA header will either open
+ // a sign-in page, account management page, or sync
+ // preferences page.
+ const { status } = UIState.get();
+ switch (status) {
+ case UIState.STATUS_NOT_CONFIGURED:
+ this.openFxAEmailFirstPageFromFxaMenu(panel);
+ break;
+ case UIState.STATUS_LOGIN_FAILED:
+ case UIState.STATUS_NOT_VERIFIED:
+ this.openPrefsFromFxaMenu("sync_settings", panel);
+ break;
+ case UIState.STATUS_SIGNED_IN:
+ this.openFxAManagePageFromFxaMenu(panel);
+ }
+ },
+
+ async openFxAEmailFirstPage(entryPoint, extraParams = {}) {
+ if (!(await FxAccounts.canConnectAccount())) {
+ return;
+ }
+ const url = await FxAccounts.config.promiseConnectAccountURI(
+ entryPoint,
+ extraParams
+ );
+ switchToTabHavingURI(url, true, { replaceQueryString: true });
+ },
+
+ async openFxAEmailFirstPageFromFxaMenu(panel = undefined, extraParams = {}) {
+ this.emitFxaToolbarTelemetry("login", panel);
+ let entryPoint = "fxa_discoverability_native";
+ if (panel) {
+ entryPoint = "fxa_app_menu";
+ }
+ this.openFxAEmailFirstPage(entryPoint, extraParams);
+ },
+
+ async openFxAManagePage(entryPoint) {
+ const url = await FxAccounts.config.promiseManageURI(entryPoint);
+ switchToTabHavingURI(url, true, { replaceQueryString: true });
+ },
+
+ async openFxAManagePageFromFxaMenu(panel = undefined) {
+ this.emitFxaToolbarTelemetry("account_settings", panel);
+ let entryPoint = "fxa_discoverability_native";
+ if (this.isPanelInsideAppMenu(panel)) {
+ entryPoint = "fxa_app_menu";
+ }
+ this.openFxAManagePage(entryPoint);
+ },
+
+ // Returns true if we managed to send the tab to any targets, false otherwise.
+ async sendTabToDevice(url, targets, title) {
+ const fxaCommandsDevices = [];
+ for (const target of targets) {
+ if (fxAccounts.commands.sendTab.isDeviceCompatible(target)) {
+ fxaCommandsDevices.push(target);
+ } else {
+ this.log.error(`Target ${target.id} unsuitable for send tab.`);
+ }
+ }
+ // If a primary-password is enabled then it must be unlocked so FxA can get
+ // the encryption keys from the login manager. (If we end up using the "sync"
+ // fallback that would end up prompting by itself, but the FxA command route
+ // will not) - so force that here.
+ let cryptoSDR = Cc["@mozilla.org/login-manager/crypto/SDR;1"].getService(
+ Ci.nsILoginManagerCrypto
+ );
+ if (!cryptoSDR.isLoggedIn) {
+ if (cryptoSDR.uiBusy) {
+ this.log.info("Master password UI is busy - not sending the tabs");
+ return false;
+ }
+ try {
+ cryptoSDR.encrypt("bacon"); // forces the mp prompt.
+ } catch (e) {
+ this.log.info(
+ "Master password remains unlocked - not sending the tabs"
+ );
+ return false;
+ }
+ }
+ let numFailed = 0;
+ if (fxaCommandsDevices.length) {
+ this.log.info(
+ `Sending a tab to ${fxaCommandsDevices
+ .map(d => d.id)
+ .join(", ")} using FxA commands.`
+ );
+ const report = await fxAccounts.commands.sendTab.send(
+ fxaCommandsDevices,
+ { url, title }
+ );
+ for (let { device, error } of report.failed) {
+ this.log.error(
+ `Failed to send a tab with FxA commands for ${device.id}.`,
+ error
+ );
+ numFailed++;
+ }
+ }
+ return numFailed < targets.length; // Good enough.
+ },
+
+ populateSendTabToDevicesMenu(
+ devicesPopup,
+ uri,
+ title,
+ multiselected,
+ createDeviceNodeFn,
+ isFxaMenu = false
+ ) {
+ uri = BrowserUtils.getShareableURL(uri);
+ if (!uri) {
+ // log an error as everyone should have already checked this.
+ this.log.error("Ignoring request to share a non-sharable URL");
+ return;
+ }
+ if (!createDeviceNodeFn) {
+ createDeviceNodeFn = (targetId, name, targetType, lastModified) => {
+ let eltName = name ? "menuitem" : "menuseparator";
+ return document.createXULElement(eltName);
+ };
+ }
+
+ // remove existing menu items
+ for (let i = devicesPopup.children.length - 1; i >= 0; --i) {
+ let child = devicesPopup.children[i];
+ if (child.classList.contains("sync-menuitem")) {
+ child.remove();
+ }
+ }
+
+ if (gSync.sendTabConfiguredAndLoading) {
+ // We can only be in this case in the page action menu.
+ return;
+ }
+
+ const fragment = document.createDocumentFragment();
+
+ const state = UIState.get();
+ if (state.status == UIState.STATUS_SIGNED_IN) {
+ const targets = this.getSendTabTargets();
+ if (targets.length) {
+ this._appendSendTabDeviceList(
+ targets,
+ fragment,
+ createDeviceNodeFn,
+ uri.spec,
+ title,
+ multiselected,
+ isFxaMenu
+ );
+ } else {
+ this._appendSendTabSingleDevice(fragment, createDeviceNodeFn);
+ }
+ } else if (
+ state.status == UIState.STATUS_NOT_VERIFIED ||
+ state.status == UIState.STATUS_LOGIN_FAILED
+ ) {
+ this._appendSendTabVerify(fragment, createDeviceNodeFn);
+ } else {
+ // The only status not handled yet is STATUS_NOT_CONFIGURED, and
+ // when we're in that state, none of the menus that call
+ // populateSendTabToDevicesMenu are available, so entering this
+ // state is unexpected.
+ throw new Error(
+ "Called populateSendTabToDevicesMenu when in STATUS_NOT_CONFIGURED " +
+ "state."
+ );
+ }
+
+ devicesPopup.appendChild(fragment);
+ },
+
+ _appendSendTabDeviceList(
+ targets,
+ fragment,
+ createDeviceNodeFn,
+ url,
+ title,
+ multiselected,
+ isFxaMenu = false
+ ) {
+ let tabsToSend = multiselected
+ ? gBrowser.selectedTabs.map(t => {
+ return {
+ url: t.linkedBrowser.currentURI.spec,
+ title: t.linkedBrowser.contentTitle,
+ };
+ })
+ : [{ url, title }];
+
+ const send = to => {
+ Promise.all(
+ tabsToSend.map(t =>
+ // sendTabToDevice does not reject.
+ this.sendTabToDevice(t.url, to, t.title)
+ )
+ ).then(results => {
+ // Show the Sent! confirmation if any of the sends succeeded.
+ if (results.includes(true)) {
+ // FxA button could be hidden with CSS since the user is logged out,
+ // although it seems likely this would only happen in testing...
+ let fxastatus = document.documentElement.getAttribute("fxastatus");
+ let anchorNode =
+ (fxastatus &&
+ fxastatus != "not_configured" &&
+ document.getElementById("fxa-toolbar-menu-button")?.parentNode
+ ?.id != "widget-overflow-list" &&
+ document.getElementById("fxa-toolbar-menu-button")) ||
+ document.getElementById("PanelUI-menu-button");
+ ConfirmationHint.show(anchorNode, "confirmation-hint-send-to-device");
+ }
+ fxAccounts.flushLogFile();
+ });
+ };
+ const onSendAllCommand = event => {
+ send(targets);
+ };
+ const onTargetDeviceCommand = event => {
+ const targetId = event.target.getAttribute("clientId");
+ const target = targets.find(t => t.id == targetId);
+ send([target]);
+ };
+
+ function addTargetDevice(targetId, name, targetType, lastModified) {
+ const targetDevice = createDeviceNodeFn(
+ targetId,
+ name,
+ targetType,
+ lastModified
+ );
+ targetDevice.addEventListener(
+ "command",
+ targetId ? onTargetDeviceCommand : onSendAllCommand,
+ true
+ );
+ targetDevice.classList.add("sync-menuitem", "sendtab-target");
+ targetDevice.setAttribute("clientId", targetId);
+ targetDevice.setAttribute("clientType", targetType);
+ targetDevice.setAttribute("label", name);
+ fragment.appendChild(targetDevice);
+ }
+
+ for (let target of targets) {
+ let type, lastModified;
+ if (target.clientRecord) {
+ type = Weave.Service.clientsEngine.getClientType(
+ target.clientRecord.id
+ );
+ lastModified = new Date(target.clientRecord.serverLastModified * 1000);
+ } else {
+ // For phones, FxA uses "mobile" and Sync clients uses "phone".
+ type = target.type == "mobile" ? "phone" : target.type;
+ lastModified = target.lastAccessTime
+ ? new Date(target.lastAccessTime)
+ : null;
+ }
+ addTargetDevice(target.id, target.name, type, lastModified);
+ }
+
+ if (targets.length > 1) {
+ // "Send to All Devices" menu item
+ const separator = createDeviceNodeFn();
+ separator.classList.add("sync-menuitem");
+ fragment.appendChild(separator);
+ const [allDevicesLabel, manageDevicesLabel] =
+ this.fluentStrings.formatValuesSync(
+ isFxaMenu
+ ? ["account-send-to-all-devices", "account-manage-devices"]
+ : [
+ "account-send-to-all-devices-titlecase",
+ "account-manage-devices-titlecase",
+ ]
+ );
+ addTargetDevice("", allDevicesLabel, "");
+
+ // "Manage devices" menu item
+ // We piggyback on the createDeviceNodeFn implementation,
+ // it's a big disgusting.
+ const targetDevice = createDeviceNodeFn(
+ null,
+ manageDevicesLabel,
+ null,
+ null
+ );
+ targetDevice.addEventListener(
+ "command",
+ () => gSync.openDevicesManagementPage("sendtab"),
+ true
+ );
+ targetDevice.classList.add("sync-menuitem", "sendtab-target");
+ targetDevice.setAttribute("label", manageDevicesLabel);
+ fragment.appendChild(targetDevice);
+ }
+ },
+
+ _appendSendTabSingleDevice(fragment, createDeviceNodeFn) {
+ const [noDevices, learnMore, connectDevice] =
+ this.fluentStrings.formatValuesSync([
+ "account-send-tab-to-device-singledevice-status",
+ "account-send-tab-to-device-singledevice-learnmore",
+ "account-send-tab-to-device-connectdevice",
+ ]);
+ const actions = [
+ {
+ label: connectDevice,
+ command: () => this.openConnectAnotherDevice("sendtab"),
+ },
+ { label: learnMore, command: () => this.openSendToDevicePromo() },
+ ];
+ this._appendSendTabInfoItems(
+ fragment,
+ createDeviceNodeFn,
+ noDevices,
+ actions
+ );
+ },
+
+ _appendSendTabVerify(fragment, createDeviceNodeFn) {
+ const [notVerified, verifyAccount] = this.fluentStrings.formatValuesSync([
+ "account-send-tab-to-device-verify-status",
+ "account-send-tab-to-device-verify",
+ ]);
+ const actions = [
+ { label: verifyAccount, command: () => this.openPrefs("sendtab") },
+ ];
+ this._appendSendTabInfoItems(
+ fragment,
+ createDeviceNodeFn,
+ notVerified,
+ actions
+ );
+ },
+
+ _appendSendTabInfoItems(fragment, createDeviceNodeFn, statusLabel, actions) {
+ const status = createDeviceNodeFn(null, statusLabel, null);
+ status.setAttribute("label", statusLabel);
+ status.setAttribute("disabled", true);
+ status.classList.add("sync-menuitem");
+ fragment.appendChild(status);
+
+ const separator = createDeviceNodeFn(null, null, null);
+ separator.classList.add("sync-menuitem");
+ fragment.appendChild(separator);
+
+ for (let { label, command } of actions) {
+ const actionItem = createDeviceNodeFn(null, label, null);
+ actionItem.addEventListener("command", command, true);
+ actionItem.classList.add("sync-menuitem");
+ actionItem.setAttribute("label", label);
+ fragment.appendChild(actionItem);
+ }
+ },
+
+ // "Send Tab to Device" menu item
+ updateTabContextMenu(aPopupMenu, aTargetTab) {
+ // We may get here before initialisation. This situation
+ // can lead to a empty label for 'Send To Device' Menu.
+ this.init();
+
+ if (!this.FXA_ENABLED) {
+ // These items are hidden in onFxaDisabled(). No need to do anything.
+ return;
+ }
+ let hasASendableURI = false;
+ for (let tab of aTargetTab.multiselected
+ ? gBrowser.selectedTabs
+ : [aTargetTab]) {
+ if (BrowserUtils.getShareableURL(tab.linkedBrowser.currentURI)) {
+ hasASendableURI = true;
+ break;
+ }
+ }
+ const enabled = !this.sendTabConfiguredAndLoading && hasASendableURI;
+ const hideItems = this.shouldHideSendContextMenuItems(enabled);
+
+ let sendTabsToDevice = document.getElementById("context_sendTabToDevice");
+ sendTabsToDevice.disabled = !enabled;
+
+ if (hideItems || !hasASendableURI) {
+ sendTabsToDevice.hidden = true;
+ } else {
+ let tabCount = aTargetTab.multiselected
+ ? gBrowser.multiSelectedTabsCount
+ : 1;
+ sendTabsToDevice.setAttribute(
+ "data-l10n-args",
+ JSON.stringify({ tabCount })
+ );
+ sendTabsToDevice.hidden = false;
+ }
+ },
+
+ // "Send Page to Device" and "Send Link to Device" menu items
+ updateContentContextMenu(contextMenu) {
+ if (!this.FXA_ENABLED) {
+ // These items are hidden by default. No need to do anything.
+ return false;
+ }
+ // showSendLink and showSendPage are mutually exclusive
+ const showSendLink =
+ contextMenu.onSaveableLink || contextMenu.onPlainTextLink;
+ const showSendPage =
+ !showSendLink &&
+ !(
+ contextMenu.isContentSelected ||
+ contextMenu.onImage ||
+ contextMenu.onCanvas ||
+ contextMenu.onVideo ||
+ contextMenu.onAudio ||
+ contextMenu.onLink ||
+ contextMenu.onTextInput
+ );
+
+ const targetURI = showSendLink
+ ? contextMenu.getLinkURI()
+ : contextMenu.browser.currentURI;
+ const enabled =
+ !this.sendTabConfiguredAndLoading &&
+ BrowserUtils.getShareableURL(targetURI);
+ const hideItems = this.shouldHideSendContextMenuItems(enabled);
+
+ contextMenu.showItem(
+ "context-sendpagetodevice",
+ !hideItems && showSendPage
+ );
+ contextMenu.showItem(
+ "context-sendlinktodevice",
+ !hideItems && showSendLink
+ );
+
+ if (!showSendLink && !showSendPage) {
+ return false;
+ }
+
+ contextMenu.setItemAttr(
+ showSendPage ? "context-sendpagetodevice" : "context-sendlinktodevice",
+ "disabled",
+ !enabled || null
+ );
+ // return true if context menu items are visible
+ return !hideItems && (showSendPage || showSendLink);
+ },
+
+ // Functions called by observers
+ onActivityStart() {
+ this._isCurrentlySyncing = true;
+ clearTimeout(this._syncAnimationTimer);
+ this._syncStartTime = Date.now();
+
+ document.querySelectorAll(".syncnow-label").forEach(el => {
+ let l10nId = el.getAttribute("syncing-data-l10n-id");
+ el.setAttribute("data-l10n-id", l10nId);
+ });
+
+ document.querySelectorAll(".syncNowBtn").forEach(el => {
+ el.setAttribute("syncstatus", "active");
+ });
+
+ document
+ .getElementById("appMenu-viewCache")
+ .content.querySelectorAll(".syncNowBtn")
+ .forEach(el => {
+ el.setAttribute("syncstatus", "active");
+ });
+ },
+
+ _onActivityStop() {
+ this._isCurrentlySyncing = false;
+ if (!gBrowser) {
+ return;
+ }
+
+ document.querySelectorAll(".syncnow-label").forEach(el => {
+ let l10nId = el.getAttribute("sync-now-data-l10n-id");
+ el.setAttribute("data-l10n-id", l10nId);
+ });
+
+ document.querySelectorAll(".syncNowBtn").forEach(el => {
+ el.removeAttribute("syncstatus");
+ });
+
+ document
+ .getElementById("appMenu-viewCache")
+ .content.querySelectorAll(".syncNowBtn")
+ .forEach(el => {
+ el.removeAttribute("syncstatus");
+ });
+
+ Services.obs.notifyObservers(null, "test:browser-sync:activity-stop");
+ },
+
+ onActivityStop() {
+ let now = Date.now();
+ let syncDuration = now - this._syncStartTime;
+
+ if (syncDuration < MIN_STATUS_ANIMATION_DURATION) {
+ let animationTime = MIN_STATUS_ANIMATION_DURATION - syncDuration;
+ clearTimeout(this._syncAnimationTimer);
+ this._syncAnimationTimer = setTimeout(
+ () => this._onActivityStop(),
+ animationTime
+ );
+ } else {
+ this._onActivityStop();
+ }
+ },
+
+ // Disconnect from sync, and optionally disconnect from the FxA account.
+ // Returns true if the disconnection happened (ie, if the user didn't decline
+ // when asked to confirm)
+ async disconnect({ confirm = true, disconnectAccount = true } = {}) {
+ if (disconnectAccount) {
+ let deleteLocalData = false;
+ if (confirm) {
+ let options = await this._confirmFxaAndSyncDisconnect();
+ if (!options.userConfirmedDisconnect) {
+ return false;
+ }
+ deleteLocalData = options.deleteLocalData;
+ }
+ return this._disconnectFxaAndSync(deleteLocalData);
+ }
+
+ if (confirm && !(await this._confirmSyncDisconnect())) {
+ return false;
+ }
+ return this._disconnectSync();
+ },
+
+ // Prompt the user to confirm disconnect from FxA and sync with the option
+ // to delete syncable data from the device.
+ async _confirmFxaAndSyncDisconnect() {
+ let options = {
+ userConfirmedDisconnect: false,
+ deleteLocalData: false,
+ };
+
+ let [title, body, button, checkbox] = await document.l10n.formatValues([
+ { id: "fxa-signout-dialog2-title" },
+ { id: "fxa-signout-dialog-body" },
+ { id: "fxa-signout-dialog2-button" },
+ { id: "fxa-signout-dialog2-checkbox" },
+ ]);
+
+ const flags =
+ Services.prompt.BUTTON_TITLE_IS_STRING * Services.prompt.BUTTON_POS_0 +
+ Services.prompt.BUTTON_TITLE_CANCEL * Services.prompt.BUTTON_POS_1;
+
+ if (!UIState.get().syncEnabled) {
+ checkbox = null;
+ }
+
+ const result = await Services.prompt.asyncConfirmEx(
+ window.browsingContext,
+ Services.prompt.MODAL_TYPE_INTERNAL_WINDOW,
+ title,
+ body,
+ flags,
+ button,
+ null,
+ null,
+ checkbox,
+ false
+ );
+ const propBag = result.QueryInterface(Ci.nsIPropertyBag2);
+ options.userConfirmedDisconnect = propBag.get("buttonNumClicked") == 0;
+ options.deleteLocalData = propBag.get("checked");
+
+ return options;
+ },
+
+ async _disconnectFxaAndSync(deleteLocalData) {
+ const { SyncDisconnect } = ChromeUtils.importESModule(
+ "resource://services-sync/SyncDisconnect.sys.mjs"
+ );
+ // Record telemetry.
+ await fxAccounts.telemetry.recordDisconnection(null, "ui");
+
+ await SyncDisconnect.disconnect(deleteLocalData).catch(e => {
+ console.error("Failed to disconnect.", e);
+ });
+
+ return true;
+ },
+
+ // Prompt the user to confirm disconnect from sync. In this case the data
+ // on the device is not deleted.
+ async _confirmSyncDisconnect() {
+ const [title, body, button] = await document.l10n.formatValues([
+ { id: `sync-disconnect-dialog-title2` },
+ { id: `sync-disconnect-dialog-body` },
+ { id: "sync-disconnect-dialog-button" },
+ ]);
+
+ const flags =
+ Services.prompt.BUTTON_TITLE_IS_STRING * Services.prompt.BUTTON_POS_0 +
+ Services.prompt.BUTTON_TITLE_CANCEL * Services.prompt.BUTTON_POS_1;
+
+ // buttonPressed will be 0 for disconnect, 1 for cancel.
+ const buttonPressed = Services.prompt.confirmEx(
+ window,
+ title,
+ body,
+ flags,
+ button,
+ null,
+ null,
+ null,
+ {}
+ );
+ return buttonPressed == 0;
+ },
+
+ async _disconnectSync() {
+ await fxAccounts.telemetry.recordDisconnection("sync", "ui");
+
+ await Weave.Service.promiseInitialized;
+ await Weave.Service.startOver();
+
+ return true;
+ },
+
+ // doSync forces a sync - it *does not* return a promise as it is called
+ // via the various UI components.
+ doSync() {
+ if (!UIState.isReady()) {
+ return;
+ }
+ // Note we don't bother checking if sync is actually enabled - none of the
+ // UI which calls this function should be visible in that case.
+ const state = UIState.get();
+ if (state.status == UIState.STATUS_SIGNED_IN) {
+ this.updateSyncStatus({ syncing: true });
+ Services.tm.dispatchToMainThread(() => {
+ // We are pretty confident that push helps us pick up all FxA commands,
+ // but some users might have issues with push, so let's unblock them
+ // by fetching the missed FxA commands on manual sync.
+ fxAccounts.commands.pollDeviceCommands().catch(e => {
+ this.log.error("Fetching missed remote commands failed.", e);
+ });
+ Weave.Service.sync();
+ });
+ }
+ },
+
+ doSyncFromFxaMenu(panel) {
+ this.doSync();
+ this.emitFxaToolbarTelemetry("sync_now", panel);
+ },
+
+ openPrefs(entryPoint = "syncbutton", origin = undefined) {
+ window.openPreferences("paneSync", {
+ origin,
+ urlParams: { entrypoint: entryPoint },
+ });
+ },
+
+ openPrefsFromFxaMenu(type, panel) {
+ this.emitFxaToolbarTelemetry(type, panel);
+ let entryPoint = "fxa_discoverability_native";
+ if (this.isPanelInsideAppMenu(panel)) {
+ entryPoint = "fxa_app_menu";
+ }
+ this.openPrefs(entryPoint);
+ },
+
+ openSyncedTabsPanel() {
+ let placement = CustomizableUI.getPlacementOfWidget("sync-button");
+ let area = placement?.area;
+ let anchor = document.getElementById("sync-button");
+ if (area == CustomizableUI.AREA_FIXED_OVERFLOW_PANEL) {
+ // The button is in the overflow panel, so we need to show the panel,
+ // then show our subview.
+ let navbar = document.getElementById(CustomizableUI.AREA_NAVBAR);
+ navbar.overflowable.show().then(() => {
+ PanelUI.showSubView("PanelUI-remotetabs", anchor);
+ }, console.error);
+ } else {
+ if (
+ !anchor?.checkVisibility({ checkVisibilityCSS: true, flush: false })
+ ) {
+ anchor = document.getElementById("PanelUI-menu-button");
+ }
+ // It is placed somewhere else - just try and show it.
+ PanelUI.showSubView("PanelUI-remotetabs", anchor);
+ }
+ },
+
+ refreshSyncButtonsTooltip() {
+ const state = UIState.get();
+ this.updateSyncButtonsTooltip(state);
+ },
+
+ /* Update the tooltip for the sync icon in the main menu and in Synced Tabs.
+ If Sync is configured, the tooltip is when the last sync occurred,
+ otherwise the tooltip reflects the fact that Sync needs to be
+ (re-)configured.
+ */
+ updateSyncButtonsTooltip(state) {
+ // Sync buttons are 1/2 Sync related and 1/2 FxA related
+ let l10nId, l10nArgs;
+ switch (state.status) {
+ case UIState.STATUS_NOT_VERIFIED:
+ // "needs verification"
+ l10nId = "account-verify";
+ l10nArgs = { email: state.email };
+ break;
+ case UIState.STATUS_LOGIN_FAILED:
+ // "need to reconnect/re-enter your password"
+ l10nId = "account-reconnect";
+ l10nArgs = { email: state.email };
+ break;
+ case UIState.STATUS_NOT_CONFIGURED:
+ // Button is not shown in this state
+ break;
+ default: {
+ // Sync appears configured - format the "last synced at" time.
+ let lastSyncDate = this.formatLastSyncDate(state.lastSync);
+ if (lastSyncDate) {
+ l10nId = "appmenu-fxa-last-sync";
+ l10nArgs = { time: lastSyncDate };
+ }
+ }
+ }
+ const tooltiptext = l10nId
+ ? this.fluentStrings.formatValueSync(l10nId, l10nArgs)
+ : null;
+
+ let syncNowBtns = [
+ "PanelUI-remotetabs-syncnow",
+ "PanelUI-fxa-menu-syncnow-button",
+ ];
+ syncNowBtns.forEach(id => {
+ let el = PanelMultiView.getViewNode(document, id);
+ if (tooltiptext) {
+ el.setAttribute("tooltiptext", tooltiptext);
+ } else {
+ el.removeAttribute("tooltiptext");
+ }
+ });
+ },
+
+ get relativeTimeFormat() {
+ delete this.relativeTimeFormat;
+ return (this.relativeTimeFormat = new Services.intl.RelativeTimeFormat(
+ undefined,
+ { style: "long" }
+ ));
+ },
+
+ formatLastSyncDate(date) {
+ if (!date) {
+ // Date can be null before the first sync!
+ return null;
+ }
+ try {
+ let adjustedDate = new Date(Date.now() - 1000);
+ let relativeDateStr = this.relativeTimeFormat.formatBestUnit(
+ date < adjustedDate ? date : adjustedDate
+ );
+ return relativeDateStr;
+ } catch (ex) {
+ // shouldn't happen, but one client having an invalid date shouldn't
+ // break the entire feature.
+ this.log.warn("failed to format lastSync time", date, ex);
+ return null;
+ }
+ },
+
+ onClientsSynced() {
+ // Note that this element is only shown if Sync is enabled.
+ let element = PanelMultiView.getViewNode(
+ document,
+ "PanelUI-remotetabs-main"
+ );
+ if (element) {
+ if (Weave.Service.clientsEngine.stats.numClients > 1) {
+ element.setAttribute("devices-status", "multi");
+ } else {
+ element.setAttribute("devices-status", "single");
+ }
+ }
+ },
+
+ onFxaDisabled() {
+ document.documentElement.setAttribute("fxadisabled", true);
+
+ const toHide = [...document.querySelectorAll(".sync-ui-item")];
+ for (const item of toHide) {
+ item.hidden = true;
+ }
+ },
+
+ QueryInterface: ChromeUtils.generateQI([
+ "nsIObserver",
+ "nsISupportsWeakReference",
+ ]),
+};