tor-browser

The Tor Browser
git clone https://git.dasho.dev/tor-browser.git
Log | Files | Refs | README | LICENSE

browser-sync.js (90803B)


      1 /* This Source Code Form is subject to the terms of the Mozilla Public
      2 * License, v. 2.0. If a copy of the MPL was not distributed with this
      3 * file, You can obtain one at http://mozilla.org/MPL/2.0/. */
      4 
      5 const {
      6  FX_MONITOR_OAUTH_CLIENT_ID,
      7  FX_RELAY_OAUTH_CLIENT_ID,
      8  SCOPE_APP_SYNC,
      9  VPN_OAUTH_CLIENT_ID,
     10 } = ChromeUtils.importESModule(
     11  "resource://gre/modules/FxAccountsCommon.sys.mjs"
     12 );
     13 
     14 const { TRUSTED_FAVICON_SCHEMES, getMozRemoteImageURL } =
     15  ChromeUtils.importESModule("moz-src:///browser/modules/FaviconUtils.sys.mjs");
     16 
     17 const { UIState } = ChromeUtils.importESModule(
     18  "resource://services-sync/UIState.sys.mjs"
     19 );
     20 
     21 ChromeUtils.defineESModuleGetters(this, {
     22  ASRouter: "resource:///modules/asrouter/ASRouter.sys.mjs",
     23  EnsureFxAccountsWebChannel:
     24    "resource://gre/modules/FxAccountsWebChannel.sys.mjs",
     25 
     26  ExperimentAPI: "resource://nimbus/ExperimentAPI.sys.mjs",
     27  FxAccounts: "resource://gre/modules/FxAccounts.sys.mjs",
     28  MenuMessage: "resource:///modules/asrouter/MenuMessage.sys.mjs",
     29  SyncedTabs: "resource://services-sync/SyncedTabs.sys.mjs",
     30  SyncedTabsManagement: "resource://services-sync/SyncedTabs.sys.mjs",
     31  Weave: "resource://services-sync/main.sys.mjs",
     32 });
     33 
     34 const MIN_STATUS_ANIMATION_DURATION = 1600;
     35 
     36 this.SyncedTabsPanelList = class SyncedTabsPanelList {
     37  static sRemoteTabsDeckIndices = {
     38    DECKINDEX_TABS: 0,
     39    DECKINDEX_FETCHING: 1,
     40    DECKINDEX_TABSDISABLED: 2,
     41    DECKINDEX_NOCLIENTS: 3,
     42  };
     43 
     44  static sRemoteTabsPerPage = 25;
     45  static sRemoteTabsNextPageMinTabs = 5;
     46 
     47  constructor(panelview, deck, tabsList, separator) {
     48    this.QueryInterface = ChromeUtils.generateQI([
     49      "nsIObserver",
     50      "nsISupportsWeakReference",
     51    ]);
     52 
     53    Services.obs.addObserver(this, SyncedTabs.TOPIC_TABS_CHANGED, true);
     54    this.deck = deck;
     55    this.tabsList = tabsList;
     56    this.separator = separator;
     57    this._showSyncedTabsPromise = Promise.resolve();
     58 
     59    this.createSyncedTabs();
     60  }
     61 
     62  observe(subject, topic) {
     63    if (topic == SyncedTabs.TOPIC_TABS_CHANGED) {
     64      this._showSyncedTabs();
     65    }
     66  }
     67 
     68  createSyncedTabs() {
     69    if (SyncedTabs.isConfiguredToSyncTabs) {
     70      if (SyncedTabs.hasSyncedThisSession) {
     71        this.deck.selectedIndex =
     72          SyncedTabsPanelList.sRemoteTabsDeckIndices.DECKINDEX_TABS;
     73      } else {
     74        // Sync hasn't synced tabs yet, so show the "fetching" panel.
     75        this.deck.selectedIndex =
     76          SyncedTabsPanelList.sRemoteTabsDeckIndices.DECKINDEX_FETCHING;
     77      }
     78      // force a background sync.
     79      SyncedTabs.syncTabs().catch(ex => {
     80        console.error(ex);
     81      });
     82      this.deck.toggleAttribute("syncingtabs", true);
     83      // show the current list - it will be updated by our observer.
     84      this._showSyncedTabs();
     85      if (this.separator) {
     86        this.separator.hidden = false;
     87      }
     88    } else {
     89      // not configured to sync tabs, so no point updating the list.
     90      this.deck.selectedIndex =
     91        SyncedTabsPanelList.sRemoteTabsDeckIndices.DECKINDEX_TABSDISABLED;
     92      this.deck.toggleAttribute("syncingtabs", false);
     93      if (this.separator) {
     94        this.separator.hidden = true;
     95      }
     96    }
     97  }
     98 
     99  // Update the synced tab list after any existing in-flight updates are complete.
    100  _showSyncedTabs(paginationInfo) {
    101    this._showSyncedTabsPromise = this._showSyncedTabsPromise.then(
    102      () => {
    103        return this.__showSyncedTabs(paginationInfo);
    104      },
    105      e => {
    106        console.error(e);
    107      }
    108    );
    109  }
    110 
    111  // Return a new promise to update the tab list.
    112  __showSyncedTabs(paginationInfo) {
    113    if (!this.tabsList) {
    114      // Closed between the previous `this._showSyncedTabsPromise`
    115      // resolving and now.
    116      return undefined;
    117    }
    118    return SyncedTabs.getTabClients()
    119      .then(clients => {
    120        let noTabs = !UIState.get().syncEnabled || !clients.length;
    121        this.deck.toggleAttribute("syncingtabs", !noTabs);
    122        if (this.separator) {
    123          this.separator.hidden = noTabs;
    124        }
    125 
    126        // The view may have been hidden while the promise was resolving.
    127        if (!this.tabsList) {
    128          return;
    129        }
    130        if (clients.length === 0 && !SyncedTabs.hasSyncedThisSession) {
    131          // the "fetching tabs" deck is being shown - let's leave it there.
    132          // When that first sync completes we'll be notified and update.
    133          return;
    134        }
    135 
    136        if (clients.length === 0) {
    137          this.deck.selectedIndex =
    138            SyncedTabsPanelList.sRemoteTabsDeckIndices.DECKINDEX_NOCLIENTS;
    139          return;
    140        }
    141        this.deck.selectedIndex =
    142          SyncedTabsPanelList.sRemoteTabsDeckIndices.DECKINDEX_TABS;
    143        this._clearSyncedTabList();
    144        SyncedTabs.sortTabClientsByLastUsed(clients);
    145        let fragment = document.createDocumentFragment();
    146 
    147        let clientNumber = 0;
    148        for (let client of clients) {
    149          // add a menu separator for all clients other than the first.
    150          if (fragment.lastElementChild) {
    151            let separator = document.createXULElement("toolbarseparator");
    152            fragment.appendChild(separator);
    153          }
    154          // We add the client's elements to a container, and indicate which
    155          // element labels it.
    156          let labelId = `synced-tabs-client-${clientNumber++}`;
    157          let container = document.createXULElement("vbox");
    158          container.classList.add("PanelUI-remotetabs-clientcontainer");
    159          container.setAttribute("role", "group");
    160          container.setAttribute("aria-labelledby", labelId);
    161          let clientPaginationInfo =
    162            paginationInfo && paginationInfo.clientId == client.id
    163              ? paginationInfo
    164              : { clientId: client.id };
    165          this._appendSyncClient(
    166            client,
    167            container,
    168            labelId,
    169            clientPaginationInfo
    170          );
    171          fragment.appendChild(container);
    172        }
    173        this.tabsList.appendChild(fragment);
    174      })
    175      .catch(err => {
    176        console.error(err);
    177      })
    178      .then(() => {
    179        // an observer for tests.
    180        Services.obs.notifyObservers(
    181          null,
    182          "synced-tabs-menu:test:tabs-updated"
    183        );
    184      });
    185  }
    186 
    187  _clearSyncedTabList() {
    188    let list = this.tabsList;
    189    while (list.lastChild) {
    190      list.lastChild.remove();
    191    }
    192  }
    193 
    194  _createNoSyncedTabsElement(messageAttr, appendTo = null) {
    195    if (!appendTo) {
    196      appendTo = this.tabsList;
    197    }
    198 
    199    let messageLabel = document.createXULElement("label");
    200    document.l10n.setAttributes(
    201      messageLabel,
    202      this.tabsList.getAttribute(messageAttr)
    203    );
    204    appendTo.appendChild(messageLabel);
    205    return messageLabel;
    206  }
    207 
    208  _appendSyncClient(client, container, labelId, paginationInfo) {
    209    let { maxTabs = SyncedTabsPanelList.sRemoteTabsPerPage } = paginationInfo;
    210    // Create the element for the remote client.
    211    let clientItem = document.createXULElement("label");
    212    clientItem.setAttribute("id", labelId);
    213    clientItem.setAttribute("itemtype", "client");
    214    clientItem.setAttribute(
    215      "tooltiptext",
    216      gSync.fluentStrings.formatValueSync("appmenu-fxa-last-sync", {
    217        time: gSync.formatLastSyncDate(new Date(client.lastModified)),
    218      })
    219    );
    220    clientItem.textContent = client.name;
    221 
    222    container.appendChild(clientItem);
    223 
    224    if (!client.tabs.length) {
    225      let label = this._createNoSyncedTabsElement(
    226        "notabsforclientlabel",
    227        container
    228      );
    229      label.setAttribute("class", "PanelUI-remotetabs-notabsforclient-label");
    230    } else {
    231      // We have the client obj but we need the FxA device obj so we use the clients
    232      // engine to get us the FxA device
    233      let device =
    234        fxAccounts.device.recentDeviceList &&
    235        fxAccounts.device.recentDeviceList.find(
    236          d =>
    237            d.id === Weave.Service.clientsEngine.getClientFxaDeviceId(client.id)
    238        );
    239      let remoteTabCloseAvailable =
    240        device && fxAccounts.commands.closeTab.isDeviceCompatible(device);
    241 
    242      let tabs = client.tabs.filter(t => !t.inactive);
    243      let hasInactive = tabs.length != client.tabs.length;
    244 
    245      if (hasInactive) {
    246        container.append(this._createShowInactiveTabsElement(client, device));
    247      }
    248      // If this page isn't displaying all (regular, active) tabs, show a "Show More" button.
    249      let hasNextPage = tabs.length > maxTabs;
    250      let nextPageIsLastPage =
    251        hasNextPage &&
    252        maxTabs + SyncedTabsPanelList.sRemoteTabsPerPage >= tabs.length;
    253      if (nextPageIsLastPage) {
    254        // When the user clicks "Show More", try to have at least sRemoteTabsNextPageMinTabs more tabs
    255        // to display in order to avoid user frustration
    256        maxTabs = Math.min(
    257          tabs.length - SyncedTabsPanelList.sRemoteTabsNextPageMinTabs,
    258          maxTabs
    259        );
    260      }
    261      if (hasNextPage) {
    262        tabs = tabs.slice(0, maxTabs);
    263      }
    264      for (let [index, tab] of tabs.entries()) {
    265        let tabEnt = this._createSyncedTabElement(
    266          tab,
    267          index,
    268          device,
    269          remoteTabCloseAvailable
    270        );
    271        container.appendChild(tabEnt);
    272      }
    273      if (hasNextPage) {
    274        let showAllEnt = this._createShowMoreSyncedTabsElement(paginationInfo);
    275        container.appendChild(showAllEnt);
    276      }
    277    }
    278  }
    279 
    280  _createSyncedTabElement(tabInfo, index, device, canCloseTabs) {
    281    let tabContainer = document.createXULElement("hbox");
    282    tabContainer.setAttribute(
    283      "class",
    284      "PanelUI-tabitem-container all-tabs-item"
    285    );
    286 
    287    let item = document.createXULElement("toolbarbutton");
    288    let tooltipText = (tabInfo.title ? tabInfo.title + "\n" : "") + tabInfo.url;
    289    item.setAttribute("itemtype", "tab");
    290    item.classList.add(
    291      "all-tabs-button",
    292      "subviewbutton",
    293      "subviewbutton-iconic"
    294    );
    295    item.setAttribute("targetURI", tabInfo.url);
    296    item.setAttribute(
    297      "label",
    298      tabInfo.title != "" ? tabInfo.title : tabInfo.url
    299    );
    300    if (tabInfo.icon) {
    301      let icon = tabInfo.icon;
    302      if (gSync.REMOTE_SVG_ICON_DECODING) {
    303        try {
    304          const uri = NetUtil.newURI(icon);
    305          if (!TRUSTED_FAVICON_SCHEMES.includes(uri.scheme)) {
    306            const size = Math.floor(16 * window.devicePixelRatio);
    307            icon = getMozRemoteImageURL(uri.spec, size);
    308          }
    309        } catch (e) {
    310          console.error(e);
    311          icon = "";
    312        }
    313      }
    314      item.setAttribute("image", icon);
    315    }
    316    item.setAttribute("tooltiptext", tooltipText);
    317    // We need to use "click" instead of "command" here so openUILink
    318    // respects different buttons (eg, to open in a new tab).
    319    item.addEventListener("click", e => {
    320      // We want to differentiate between when the fxa panel is within the app menu/hamburger bar
    321      let object = window.gSync._getEntryPointForElement(e.currentTarget);
    322      SyncedTabs.recordSyncedTabsTelemetry(object, "click", {
    323        tab_pos: index.toString(),
    324      });
    325      document.defaultView.openUILink(tabInfo.url, e, {
    326        triggeringPrincipal: Services.scriptSecurityManager.createNullPrincipal(
    327          {}
    328        ),
    329      });
    330      if (BrowserUtils.whereToOpenLink(e) != "current") {
    331        e.preventDefault();
    332        e.stopPropagation();
    333      } else {
    334        CustomizableUI.hidePanelForNode(item);
    335      }
    336    });
    337    tabContainer.appendChild(item);
    338    // We should only add an X button next to tabs if the device
    339    // is broadcasting that it can remotely close tabs
    340    if (canCloseTabs) {
    341      let closeBtn = this._createCloseTabElement(tabInfo.url, device);
    342      closeBtn.tab = item;
    343      tabContainer.appendChild(closeBtn);
    344      let undoBtn = this._createUndoCloseTabElement(tabInfo.url, device);
    345      undoBtn.tab = item;
    346      tabContainer.appendChild(undoBtn);
    347    }
    348    return tabContainer;
    349  }
    350 
    351  _createShowMoreSyncedTabsElement(paginationInfo) {
    352    let showMoreItem = document.createXULElement("toolbarbutton");
    353    showMoreItem.setAttribute("itemtype", "showmorebutton");
    354    showMoreItem.setAttribute("closemenu", "none");
    355    showMoreItem.classList.add("subviewbutton", "subviewbutton-nav-down");
    356    document.l10n.setAttributes(showMoreItem, "appmenu-remote-tabs-showmore");
    357 
    358    paginationInfo.maxTabs = Infinity;
    359    showMoreItem.addEventListener("click", e => {
    360      e.preventDefault();
    361      e.stopPropagation();
    362      this._showSyncedTabs(paginationInfo);
    363    });
    364    return showMoreItem;
    365  }
    366 
    367  _createShowInactiveTabsElement(client, device) {
    368    let showItem = document.createXULElement("toolbarbutton");
    369    showItem.setAttribute("itemtype", "showinactivebutton");
    370    showItem.setAttribute("closemenu", "none");
    371    showItem.classList.add("subviewbutton", "subviewbutton-nav");
    372    document.l10n.setAttributes(
    373      showItem,
    374      "appmenu-remote-tabs-show-inactive-tabs"
    375    );
    376 
    377    let canClose =
    378      device && fxAccounts.commands.closeTab.isDeviceCompatible(device);
    379 
    380    showItem.addEventListener("click", e => {
    381      let node = PanelMultiView.getViewNode(
    382        document,
    383        "PanelUI-fxa-menu-inactive-tabs"
    384      );
    385 
    386      // device name.
    387      let label = node.querySelector("label[itemtype='client']");
    388      label.textContent = client.name;
    389 
    390      // Update the tab list.
    391      let container = node.querySelector(".panel-subview-body");
    392      container.replaceChildren(
    393        ...client.tabs
    394          .filter(t => t.inactive)
    395          .map((tab, index) =>
    396            this._createSyncedTabElement(tab, index, device, canClose)
    397          )
    398      );
    399      PanelUI.showSubView("PanelUI-fxa-menu-inactive-tabs", showItem, e);
    400    });
    401    return showItem;
    402  }
    403 
    404  _createCloseTabElement(url, device) {
    405    let closeBtn = document.createXULElement("toolbarbutton");
    406    closeBtn.classList.add(
    407      "remote-tabs-close-button",
    408      "all-tabs-close-button",
    409      "subviewbutton"
    410    );
    411    closeBtn.setAttribute("closemenu", "none");
    412    closeBtn.setAttribute(
    413      "tooltiptext",
    414      gSync.fluentStrings.formatValueSync("synced-tabs-context-close-tab", {
    415        deviceName: device.name,
    416      })
    417    );
    418    closeBtn.addEventListener("click", e => {
    419      e.stopPropagation();
    420 
    421      let tabContainer = closeBtn.parentNode;
    422      let tabList = tabContainer.parentNode;
    423 
    424      let undoBtn = tabContainer.querySelector(".remote-tabs-undo-button");
    425 
    426      let prevClose = tabList.querySelector(
    427        ".remote-tabs-undo-button:not([hidden])"
    428      );
    429      if (prevClose) {
    430        let prevCloseContainer = prevClose.parentNode;
    431        prevCloseContainer.classList.add("tabitem-removed");
    432        prevCloseContainer.addEventListener("transitionend", () => {
    433          prevCloseContainer.remove();
    434        });
    435      }
    436      closeBtn.hidden = true;
    437      undoBtn.hidden = false;
    438      // This tab has been closed so we prevent the user from
    439      // interacting with it
    440      if (closeBtn.tab) {
    441        closeBtn.tab.disabled = true;
    442      }
    443      // The user could be hitting multiple tabs across multiple devices, with a few
    444      // seconds in-between -- we should not immediately fire off pushes, so we
    445      // add it to a queue and send in bulk at a later time
    446      SyncedTabsManagement.enqueueTabToClose(device.id, url);
    447    });
    448    return closeBtn;
    449  }
    450 
    451  _createUndoCloseTabElement(url, device) {
    452    let undoBtn = document.createXULElement("toolbarbutton");
    453    undoBtn.classList.add("remote-tabs-undo-button", "subviewbutton");
    454    undoBtn.setAttribute("closemenu", "none");
    455    undoBtn.setAttribute("data-l10n-id", "text-action-undo");
    456    undoBtn.hidden = true;
    457 
    458    undoBtn.addEventListener("click", function (e) {
    459      e.stopPropagation();
    460 
    461      undoBtn.hidden = true;
    462      let closeBtn = undoBtn.parentNode.querySelector(".all-tabs-close-button");
    463      closeBtn.hidden = false;
    464      if (undoBtn.tab) {
    465        undoBtn.tab.disabled = false;
    466      }
    467 
    468      // remove this tab from being remotely closed
    469      SyncedTabsManagement.removePendingTabToClose(device.id, url);
    470    });
    471    return undoBtn;
    472  }
    473 
    474  destroy() {
    475    Services.obs.removeObserver(this, SyncedTabs.TOPIC_TABS_CHANGED);
    476    this.tabsList = null;
    477    this.deck = null;
    478    this.separator = null;
    479  }
    480 };
    481 
    482 var gSync = {
    483  _initialized: false,
    484  _isCurrentlySyncing: false,
    485  // The last sync start time. Used to calculate the leftover animation time
    486  // once syncing completes (bug 1239042).
    487  _syncStartTime: 0,
    488  _syncAnimationTimer: 0,
    489  _obs: ["weave:engine:sync:finish", "quit-application", UIState.ON_UPDATE],
    490 
    491  get log() {
    492    if (!this._log) {
    493      const { Log } = ChromeUtils.importESModule(
    494        "resource://gre/modules/Log.sys.mjs"
    495      );
    496      let syncLog = Log.repository.getLogger("Sync.Browser");
    497      syncLog.manageLevelFromPref("services.sync.log.logger.browser");
    498      this._log = syncLog;
    499    }
    500    return this._log;
    501  },
    502 
    503  get fluentStrings() {
    504    delete this.fluentStrings;
    505    return (this.fluentStrings = new Localization(
    506      [
    507        "branding/brand.ftl",
    508        "browser/accounts.ftl",
    509        "browser/appmenu.ftl",
    510        "browser/sync.ftl",
    511        "browser/syncedTabs.ftl",
    512        "browser/newtab/asrouter.ftl",
    513      ],
    514      true
    515    ));
    516  },
    517 
    518  // Returns true if FxA is configured, but the send tab targets list isn't
    519  // ready yet.
    520  get sendTabConfiguredAndLoading() {
    521    const state = UIState.get();
    522    return (
    523      state.status == UIState.STATUS_SIGNED_IN &&
    524      state.syncEnabled &&
    525      !fxAccounts.device.recentDeviceList
    526    );
    527  },
    528 
    529  get isSignedIn() {
    530    return UIState.get().status == UIState.STATUS_SIGNED_IN;
    531  },
    532 
    533  shouldHideSendContextMenuItems(enabled) {
    534    const state = UIState.get();
    535    // Only show the "Send..." context menu items when sending would be possible
    536    if (
    537      enabled &&
    538      state.status == UIState.STATUS_SIGNED_IN &&
    539      state.syncEnabled &&
    540      this.getSendTabTargets().length
    541    ) {
    542      return false;
    543    }
    544    return true;
    545  },
    546 
    547  getSendTabTargets() {
    548    const targets = [];
    549    const state = UIState.get();
    550    if (
    551      state.status != UIState.STATUS_SIGNED_IN ||
    552      !state.syncEnabled ||
    553      !fxAccounts.device.recentDeviceList
    554    ) {
    555      return targets;
    556    }
    557    for (let d of fxAccounts.device.recentDeviceList) {
    558      if (d.isCurrentDevice) {
    559        continue;
    560      }
    561 
    562      if (fxAccounts.commands.sendTab.isDeviceCompatible(d)) {
    563        targets.push(d);
    564      }
    565    }
    566    return targets.sort((a, b) => b.lastAccessTime - a.lastAccessTime);
    567  },
    568 
    569  _definePrefGetters() {
    570    XPCOMUtils.defineLazyPreferenceGetter(
    571      this,
    572      "FXA_ENABLED",
    573      "identity.fxaccounts.enabled"
    574    );
    575    XPCOMUtils.defineLazyPreferenceGetter(
    576      this,
    577      "FXA_CTA_MENU_ENABLED",
    578      "identity.fxaccounts.toolbar.pxiToolbarEnabled"
    579    );
    580    XPCOMUtils.defineLazyPreferenceGetter(
    581      this,
    582      "REMOTE_SVG_ICON_DECODING",
    583      "browser.tabs.remoteSVGIconDecoding"
    584    );
    585  },
    586 
    587  maybeUpdateUIState() {
    588    // Update the UI.
    589    if (UIState.isReady()) {
    590      const state = UIState.get();
    591      // If we are not configured, the UI is already in the right state when
    592      // we open the window. We can avoid a repaint.
    593      if (state.status != UIState.STATUS_NOT_CONFIGURED) {
    594        this.updateAllUI(state);
    595      }
    596    }
    597  },
    598 
    599  init() {
    600    if (this._initialized) {
    601      return;
    602    }
    603 
    604    this._definePrefGetters();
    605 
    606    if (!this.FXA_ENABLED) {
    607      this.onFxaDisabled();
    608      return;
    609    }
    610 
    611    MozXULElement.insertFTLIfNeeded("browser/sync.ftl");
    612    MozXULElement.insertFTLIfNeeded("browser/newtab/asrouter.ftl");
    613 
    614    // Label for the sync buttons.
    615    const appMenuLabel = PanelMultiView.getViewNode(
    616      document,
    617      "appMenu-fxa-label2"
    618    );
    619    if (!appMenuLabel) {
    620      // We are in a window without our elements - just abort now, without
    621      // setting this._initialized, so we don't attempt to remove observers.
    622      return;
    623    }
    624    // We start with every menuitem hidden (except for the "setup sync" state),
    625    // so that we don't need to init the sync UI on windows like pageInfo.xhtml
    626    // (see bug 1384856).
    627    // maybeUpdateUIState() also optimizes for this - if we should be in the
    628    // "setup sync" state, that function assumes we are already in it and
    629    // doesn't re-initialize the UI elements.
    630    document.getElementById("sync-setup").hidden = false;
    631    PanelMultiView.getViewNode(
    632      document,
    633      "PanelUI-remotetabs-setupsync"
    634    ).hidden = false;
    635 
    636    const appMenuHeaderTitle = PanelMultiView.getViewNode(
    637      document,
    638      "appMenu-header-title"
    639    );
    640    const appMenuHeaderDescription = PanelMultiView.getViewNode(
    641      document,
    642      "appMenu-header-description"
    643    );
    644    const appMenuHeaderText = PanelMultiView.getViewNode(
    645      document,
    646      "appMenu-fxa-text"
    647    );
    648    appMenuHeaderTitle.hidden = true;
    649    // We must initialize the label attribute here instead of the markup
    650    // due to a timing error. The fluent label attribute was being applied
    651    // after we had updated appMenuLabel and thus displayed an incorrect
    652    // label for signed in users.
    653    const [headerDesc, headerText] = this.fluentStrings.formatValuesSync([
    654      "appmenu-fxa-signed-in-label",
    655      "appmenu-fxa-sync-and-save-data2",
    656    ]);
    657    appMenuHeaderDescription.value = headerDesc;
    658    appMenuHeaderText.textContent = headerText;
    659 
    660    for (let topic of this._obs) {
    661      Services.obs.addObserver(this, topic, true);
    662    }
    663 
    664    this.maybeUpdateUIState();
    665 
    666    EnsureFxAccountsWebChannel();
    667 
    668    let fxaPanelView = PanelMultiView.getViewNode(document, "PanelUI-fxa");
    669    fxaPanelView.addEventListener("ViewShowing", this);
    670    fxaPanelView.addEventListener("ViewHiding", this);
    671    fxaPanelView.addEventListener("command", this);
    672    PanelMultiView.getViewNode(
    673      document,
    674      "PanelUI-fxa-menu-syncnow-button"
    675    ).addEventListener("mouseover", this);
    676    PanelMultiView.getViewNode(
    677      document,
    678      "PanelUI-fxa-menu-sendtab-not-configured-button"
    679    ).addEventListener("command", this);
    680    PanelMultiView.getViewNode(
    681      document,
    682      "PanelUI-fxa-menu-sendtab-connect-device-button"
    683    ).addEventListener("command", this);
    684 
    685    PanelUI.mainView.addEventListener("ViewShowing", this);
    686 
    687    // If the experiment is enabled, we'll need to update the panels
    688    // to show some different text to the user
    689    if (this.FXA_CTA_MENU_ENABLED) {
    690      this.updateFxAPanel(UIState.get());
    691      this.updateCTAPanel();
    692    }
    693 
    694    const avatarIconVariant =
    695      NimbusFeatures.fxaButtonVisibility.getVariable("avatarIconVariant");
    696    if (avatarIconVariant) {
    697      this.applyAvatarIconVariant(avatarIconVariant);
    698    }
    699 
    700    this._initialized = true;
    701  },
    702 
    703  uninit() {
    704    if (!this._initialized) {
    705      return;
    706    }
    707 
    708    for (let topic of this._obs) {
    709      Services.obs.removeObserver(this, topic);
    710    }
    711 
    712    this._initialized = false;
    713  },
    714 
    715  handleEvent(event) {
    716    switch (event.type) {
    717      case "mouseover":
    718        this.refreshSyncButtonsTooltip();
    719        break;
    720      case "command": {
    721        this.onCommand(event.target);
    722        break;
    723      }
    724      case "ViewShowing": {
    725        if (event.target == PanelUI.mainView) {
    726          this.onAppMenuShowing();
    727        } else {
    728          this.onFxAPanelViewShowing(event.target);
    729        }
    730        break;
    731      }
    732      case "ViewHiding": {
    733        this.onFxAPanelViewHiding(event.target);
    734      }
    735    }
    736  },
    737 
    738  onAppMenuShowing() {
    739    const appMenuHeaderText = PanelMultiView.getViewNode(
    740      document,
    741      "appMenu-fxa-text"
    742    );
    743 
    744    const ctaDefaultStringID = "appmenu-fxa-sync-and-save-data2";
    745    const ctaStringID = this.getMenuCtaCopy(NimbusFeatures.fxaAppMenuItem);
    746 
    747    document.l10n.setAttributes(
    748      appMenuHeaderText,
    749      ctaStringID || ctaDefaultStringID
    750    );
    751 
    752    if (NimbusFeatures.fxaAppMenuItem.getVariable("ctaCopyVariant")) {
    753      NimbusFeatures.fxaAppMenuItem.recordExposureEvent();
    754    }
    755  },
    756 
    757  onFxAPanelViewShowing(panelview) {
    758    let messageId = panelview.getAttribute(
    759      MenuMessage.SHOWING_FXA_MENU_MESSAGE_ATTR
    760    );
    761    if (messageId) {
    762      MenuMessage.recordMenuMessageTelemetry(
    763        "IMPRESSION",
    764        MenuMessage.SOURCES.PXI_MENU,
    765        messageId
    766      );
    767      let message = ASRouter.getMessageById(messageId);
    768      ASRouter.addImpression(message);
    769    }
    770 
    771    let syncNowBtn = panelview.querySelector(".syncnow-label");
    772    let l10nId = syncNowBtn.getAttribute(
    773      this._isCurrentlySyncing
    774        ? "syncing-data-l10n-id"
    775        : "sync-now-data-l10n-id"
    776    );
    777    document.l10n.setAttributes(syncNowBtn, l10nId);
    778 
    779    // This needs to exist because if the user is signed in
    780    // but the user disabled or disconnected sync we should not show the button
    781    const syncPrefsButtonEl = PanelMultiView.getViewNode(
    782      document,
    783      "PanelUI-fxa-menu-sync-prefs-button"
    784    );
    785    const syncEnabled = UIState.get().syncEnabled;
    786    syncPrefsButtonEl.hidden = !syncEnabled;
    787    if (!syncEnabled) {
    788      this._disableSyncOffIndicator();
    789    }
    790 
    791    // We should ensure that we do not show the sign out button
    792    // if the user is not signed in
    793    const signOutButtonEl = PanelMultiView.getViewNode(
    794      document,
    795      "PanelUI-fxa-menu-account-signout-button"
    796    );
    797    signOutButtonEl.hidden = !this.isSignedIn;
    798 
    799    panelview.syncedTabsPanelList = new SyncedTabsPanelList(
    800      panelview,
    801      PanelMultiView.getViewNode(document, "PanelUI-fxa-remotetabs-deck"),
    802      PanelMultiView.getViewNode(document, "PanelUI-fxa-remotetabs-tabslist"),
    803      PanelMultiView.getViewNode(document, "PanelUI-remote-tabs-separator")
    804    );
    805 
    806    // Any variant on the CTA will have been applied inside of updateFxAPanel,
    807    // but now that the panel is showing, we record exposure.
    808    const ctaCopyVariant =
    809      NimbusFeatures.fxaAvatarMenuItem.getVariable("ctaCopyVariant");
    810    if (ctaCopyVariant) {
    811      NimbusFeatures.fxaAvatarMenuItem.recordExposureEvent();
    812    }
    813  },
    814 
    815  onFxAPanelViewHiding(panelview) {
    816    MenuMessage.hidePxiMenuMessage(gBrowser.selectedBrowser);
    817    panelview.syncedTabsPanelList.destroy();
    818    panelview.syncedTabsPanelList = null;
    819  },
    820 
    821  onCommand(button) {
    822    switch (button.id) {
    823      case "PanelUI-fxa-menu-sync-prefs-button":
    824        this.openPrefsFromFxaMenu("sync_settings", button);
    825        break;
    826      case "PanelUI-fxa-menu-setup-sync-button":
    827        this.openSyncSetup("sync_settings", button);
    828        break;
    829 
    830      case "PanelUI-fxa-menu-sendtab-connect-device-button":
    831      // fall through
    832      case "PanelUI-fxa-menu-connect-device-button":
    833        this.clickOpenConnectAnotherDevice(button);
    834        break;
    835 
    836      case "fxa-manage-account-button":
    837        this.clickFxAMenuHeaderButton(button);
    838        break;
    839      case "PanelUI-fxa-menu-syncnow-button":
    840        this.doSyncFromFxaMenu(button);
    841        break;
    842      case "PanelUI-fxa-menu-sendtab-button":
    843        this.showSendToDeviceViewFromFxaMenu(button);
    844        break;
    845      case "PanelUI-fxa-menu-account-signout-button":
    846        this.disconnect();
    847        break;
    848      case "PanelUI-fxa-menu-monitor-button":
    849        this.openMonitorLink(button);
    850        break;
    851      case "PanelUI-services-menu-relay-button":
    852      case "PanelUI-fxa-menu-relay-button":
    853        this.openRelayLink(button);
    854        break;
    855      case "PanelUI-fxa-menu-vpn-button":
    856        this.openVPNLink(button);
    857        break;
    858      case "PanelUI-fxa-menu-sendtab-not-configured-button":
    859        this.openSyncSetup("send_tab", button);
    860        break;
    861    }
    862  },
    863 
    864  observe(subject, topic, data) {
    865    if (!this._initialized) {
    866      console.error("browser-sync observer called after unload: ", topic);
    867      return;
    868    }
    869    switch (topic) {
    870      case UIState.ON_UPDATE: {
    871        const state = UIState.get();
    872        this.updateAllUI(state);
    873        break;
    874      }
    875      case "quit-application":
    876        // Stop the animation timer on shutdown, since we can't update the UI
    877        // after this.
    878        clearTimeout(this._syncAnimationTimer);
    879        break;
    880      case "weave:engine:sync:finish":
    881        if (data != "clients") {
    882          return;
    883        }
    884        this.onClientsSynced();
    885        this.updateFxAPanel(UIState.get());
    886        break;
    887    }
    888  },
    889 
    890  updateAllUI(state) {
    891    this.updatePanelPopup(state);
    892    this.updateState(state);
    893    this.updateSyncButtonsTooltip(state);
    894    this.updateSyncStatus(state);
    895    this.updateFxAPanel(state);
    896    this.ensureFxaDevices();
    897    this.fetchListOfOAuthClients();
    898  },
    899 
    900  // Ensure we have *something* in `fxAccounts.device.recentDeviceList` as some
    901  // of our UI logic depends on it not being null. When FxA is notified of a
    902  // device change it will auto refresh `recentDeviceList`, and all UI which
    903  // shows the device list will start with `recentDeviceList`, but should also
    904  // force a refresh, both of which should mean in the worst-case, the UI is up
    905  // to date after a very short delay.
    906  async ensureFxaDevices() {
    907    if (UIState.get().status != UIState.STATUS_SIGNED_IN) {
    908      console.info("Skipping device list refresh; not signed in");
    909      return;
    910    }
    911    if (!fxAccounts.device.recentDeviceList) {
    912      if (await this.refreshFxaDevices()) {
    913        // Assuming we made the call successfully it should be impossible to end
    914        // up with a falsey recentDeviceList, so make noise if that's false.
    915        if (!fxAccounts.device.recentDeviceList) {
    916          console.warn("Refreshing device list didn't find any devices.");
    917        }
    918      }
    919    }
    920  },
    921 
    922  // Force a refresh of the fxa device list.  Note that while it's theoretically
    923  // OK to call `fxAccounts.device.refreshDeviceList` multiple times concurrently
    924  // and regularly, this call tells it to avoid those protections, so will always
    925  // hit the FxA servers - therefore, you should be very careful how often you
    926  // call this.
    927  // Returns Promise<bool> to indicate whether a refresh was actually done.
    928  async refreshFxaDevices() {
    929    if (UIState.get().status != UIState.STATUS_SIGNED_IN) {
    930      console.info("Skipping device list refresh; not signed in");
    931      return false;
    932    }
    933    try {
    934      // Do the actual refresh telling it to avoid the "flooding" protections.
    935      await fxAccounts.device.refreshDeviceList({ ignoreCached: true });
    936      return true;
    937    } catch (e) {
    938      this.log.error("Refreshing device list failed.", e);
    939      return false;
    940    }
    941  },
    942 
    943  /**
    944   * Potential network call. Fetch the list of OAuth clients attached to the current Mozilla account.
    945   *
    946   * @returns {Promise<boolean>} - Resolves to true if successful, false otherwise.
    947   */
    948  async fetchListOfOAuthClients() {
    949    if (!this.isSignedIn) {
    950      console.info("Skipping fetching other attached clients");
    951      return false;
    952    }
    953    try {
    954      this._attachedClients = await fxAccounts.listAttachedOAuthClients();
    955      return true;
    956    } catch (e) {
    957      this.log.error("Could not fetch attached OAuth clients", e);
    958      return false;
    959    }
    960  },
    961 
    962  updateSendToDeviceTitle() {
    963    const tabCount = gBrowser.selectedTab.multiselected
    964      ? gBrowser.selectedTabs.length
    965      : 1;
    966    document.l10n.setArgs(
    967      PanelMultiView.getViewNode(document, "PanelUI-fxa-menu-sendtab-button"),
    968      { tabCount }
    969    );
    970  },
    971 
    972  showSendToDeviceView(anchor) {
    973    PanelUI.showSubView("PanelUI-sendTabToDevice", anchor);
    974    let panelViewNode = document.getElementById("PanelUI-sendTabToDevice");
    975    this._populateSendTabToDevicesView(panelViewNode);
    976  },
    977 
    978  showSendToDeviceViewFromFxaMenu(anchor) {
    979    const state = UIState.get();
    980    if (state.status !== UIState.STATUS_SIGNED_IN || !state.syncEnabled) {
    981      PanelUI.showSubView("PanelUI-fxa-menu-sendtab-not-configured", anchor);
    982      return;
    983    }
    984 
    985    const targets = this.sendTabConfiguredAndLoading
    986      ? []
    987      : this.getSendTabTargets();
    988    if (!targets.length) {
    989      PanelUI.showSubView("PanelUI-fxa-menu-sendtab-no-devices", anchor);
    990      return;
    991    }
    992 
    993    this.showSendToDeviceView(anchor);
    994    this.emitFxaToolbarTelemetry("send_tab", anchor);
    995  },
    996 
    997  _populateSendTabToDevicesView(panelViewNode, reloadDevices = true) {
    998    let bodyNode = panelViewNode.querySelector(".panel-subview-body");
    999    let panelNode = panelViewNode.closest("panel");
   1000    let browser = gBrowser.selectedBrowser;
   1001    let uri = browser.currentURI;
   1002    let title = browser.contentTitle;
   1003    let multiselected = gBrowser.selectedTab.multiselected;
   1004 
   1005    // This is on top because it also clears the device list between state
   1006    // changes.
   1007    this.populateSendTabToDevicesMenu(
   1008      bodyNode,
   1009      uri,
   1010      title,
   1011      multiselected,
   1012      (clientId, name, clientType, lastModified) => {
   1013        if (!name) {
   1014          return document.createXULElement("toolbarseparator");
   1015        }
   1016        let item = document.createXULElement("toolbarbutton");
   1017        item.setAttribute("wrap", true);
   1018        item.setAttribute("align", "start");
   1019        item.classList.add("sendToDevice-device", "subviewbutton");
   1020        if (clientId) {
   1021          item.classList.add("subviewbutton-iconic");
   1022          if (lastModified) {
   1023            let lastSyncDate = gSync.formatLastSyncDate(lastModified);
   1024            if (lastSyncDate) {
   1025              item.setAttribute(
   1026                "tooltiptext",
   1027                this.fluentStrings.formatValueSync("appmenu-fxa-last-sync", {
   1028                  time: lastSyncDate,
   1029                })
   1030              );
   1031            }
   1032          }
   1033        }
   1034 
   1035        item.addEventListener("command", () => {
   1036          if (panelNode) {
   1037            PanelMultiView.hidePopup(panelNode);
   1038          }
   1039        });
   1040        return item;
   1041      },
   1042      true
   1043    );
   1044 
   1045    bodyNode.removeAttribute("state");
   1046    // If the app just started, we won't have fetched the device list yet. Sync
   1047    // does this automatically ~10 sec after startup, but there's no trigger for
   1048    // this if we're signed in to FxA, but not Sync.
   1049    if (gSync.sendTabConfiguredAndLoading) {
   1050      bodyNode.setAttribute("state", "notready");
   1051    }
   1052    if (reloadDevices) {
   1053      // Force a refresh of the fxa device list in case the user connected a new
   1054      // device, and is waiting for it to show up.
   1055      this.refreshFxaDevices().then(_ => {
   1056        if (!window.closed) {
   1057          this._populateSendTabToDevicesView(panelViewNode, false);
   1058        }
   1059      });
   1060    }
   1061  },
   1062 
   1063  async toggleAccountPanel(anchor = null, aEvent) {
   1064    // Don't show the panel if the window is in customization mode.
   1065    if (document.documentElement.hasAttribute("customizing")) {
   1066      return;
   1067    }
   1068 
   1069    if (
   1070      (aEvent.type == "mousedown" && aEvent.button != 0) ||
   1071      (aEvent.type == "keypress" &&
   1072        aEvent.charCode != KeyEvent.DOM_VK_SPACE &&
   1073        aEvent.keyCode != KeyEvent.DOM_VK_RETURN)
   1074    ) {
   1075      return;
   1076    }
   1077 
   1078    const fxaToolbarMenuBtn = document.getElementById(
   1079      "fxa-toolbar-menu-button"
   1080    );
   1081 
   1082    if (anchor === null) {
   1083      anchor = fxaToolbarMenuBtn;
   1084    }
   1085 
   1086    if (anchor == fxaToolbarMenuBtn && anchor.getAttribute("open") != "true") {
   1087      if (ASRouter.initialized) {
   1088        await ASRouter.sendTriggerMessage({
   1089          browser: gBrowser.selectedBrowser,
   1090          id: "menuOpened",
   1091          context: { source: MenuMessage.SOURCES.PXI_MENU },
   1092        });
   1093      }
   1094    }
   1095 
   1096    // We read the state that's been set on the root node, since that makes
   1097    // it easier to test the various front-end states without having to actually
   1098    // have UIState know about it.
   1099    let fxaStatus = document.documentElement.getAttribute("fxastatus");
   1100 
   1101    if (fxaStatus == "not_configured") {
   1102      // sign in button in app (hamburger) menu
   1103      // should take you straight to fxa sign in page
   1104      if (anchor.id == "appMenu-fxa-label2") {
   1105        this.openFxAEmailFirstPageFromFxaMenu(anchor);
   1106        PanelUI.hide();
   1107        return;
   1108      }
   1109 
   1110      // If we're signed out but have the PXI pref enabled
   1111      // we should show the PXI panel instead of taking the user
   1112      // straight to FxA sign-in
   1113      if (this.FXA_CTA_MENU_ENABLED) {
   1114        this.updateFxAPanel(UIState.get());
   1115        this.updateCTAPanel(anchor);
   1116        PanelUI.showSubView("PanelUI-fxa", anchor, aEvent);
   1117      } else if (anchor == fxaToolbarMenuBtn) {
   1118        // The fxa toolbar button doesn't have much context before the user
   1119        // clicks it so instead of going straight to the login page,
   1120        // we take them to a page that has more information
   1121        this.emitFxaToolbarTelemetry("toolbar_icon", anchor);
   1122        openTrustedLinkIn("about:preferences#sync", "tab");
   1123        PanelUI.hide();
   1124      }
   1125      return;
   1126    }
   1127    // If the user is signed in and we have the PXI pref enabled then add
   1128    // the pxi panel to the existing toolbar
   1129    if (this.FXA_CTA_MENU_ENABLED) {
   1130      this.updateCTAPanel(anchor);
   1131    }
   1132 
   1133    if (!gFxaToolbarAccessed) {
   1134      Services.prefs.setBoolPref("identity.fxaccounts.toolbar.accessed", true);
   1135    }
   1136 
   1137    this.enableSendTabIfValidTab();
   1138 
   1139    if (!this.getSendTabTargets().length) {
   1140      for (const id of [
   1141        "PanelUI-fxa-menu-sendtab-button",
   1142        "PanelUI-fxa-menu-sendtab-separator",
   1143      ]) {
   1144        PanelMultiView.getViewNode(document, id).hidden = true;
   1145      }
   1146    }
   1147 
   1148    if (anchor.getAttribute("open") == "true") {
   1149      PanelUI.hide();
   1150    } else {
   1151      this.emitFxaToolbarTelemetry("toolbar_icon", anchor);
   1152      PanelUI.showSubView("PanelUI-fxa", anchor, aEvent);
   1153    }
   1154  },
   1155 
   1156  _disableSyncOffIndicator() {
   1157    const SYNC_PANEL_ACCESSED_PREF =
   1158      "identity.fxaccounts.toolbar.syncSetup.panelAccessed";
   1159    if (!Services.prefs.getBoolPref(SYNC_PANEL_ACCESSED_PREF, false)) {
   1160      // Turn off the indicator so the user doesn't see it in subsequent openings
   1161      Services.prefs.setBoolPref(SYNC_PANEL_ACCESSED_PREF, true);
   1162    }
   1163  },
   1164 
   1165  _shouldShowSyncOffIndicator() {
   1166    // We only ever want to show the user the dot once, once they've clicked into the panel
   1167    // we do not show them the dot anymore
   1168    return !Services.prefs.getBoolPref(
   1169      "identity.fxaccounts.toolbar.syncSetup.panelAccessed",
   1170      false
   1171    );
   1172  },
   1173 
   1174  updateFxAPanel(state = {}) {
   1175    const expandedSignInCopy =
   1176      NimbusFeatures.expandSignInButton.getVariable("ctaCopyVariant");
   1177    const mainWindowEl = document.documentElement;
   1178 
   1179    const menuHeaderTitleEl = PanelMultiView.getViewNode(
   1180      document,
   1181      "fxa-menu-header-title"
   1182    );
   1183    const menuHeaderDescriptionEl = PanelMultiView.getViewNode(
   1184      document,
   1185      "fxa-menu-header-description"
   1186    );
   1187    const cadButtonEl = PanelMultiView.getViewNode(
   1188      document,
   1189      "PanelUI-fxa-menu-connect-device-button"
   1190    );
   1191    const syncNowButtonEl = PanelMultiView.getViewNode(
   1192      document,
   1193      "PanelUI-fxa-menu-syncnow-button"
   1194    );
   1195    const fxaMenuAccountButtonEl = PanelMultiView.getViewNode(
   1196      document,
   1197      "fxa-manage-account-button"
   1198    );
   1199    const signedInContainer = PanelMultiView.getViewNode(
   1200      document,
   1201      "PanelUI-signedin-panel"
   1202    );
   1203    const emptyProfilesButton = PanelMultiView.getViewNode(
   1204      document,
   1205      "PanelUI-fxa-menu-empty-profiles-button"
   1206    );
   1207    const profilesButton = PanelMultiView.getViewNode(
   1208      document,
   1209      "PanelUI-fxa-menu-profiles-button"
   1210    );
   1211    const profilesSeparator = PanelMultiView.getViewNode(
   1212      document,
   1213      "PanelUI-fxa-menu-profiles-separator"
   1214    );
   1215    const syncSetupEl = PanelMultiView.getViewNode(
   1216      document,
   1217      "PanelUI-fxa-menu-setup-sync-container"
   1218    );
   1219    const fxaToolbarMenuButton = document.getElementById(
   1220      "fxa-toolbar-menu-button"
   1221    );
   1222    const syncSetupSeparator = PanelMultiView.getViewNode(
   1223      document,
   1224      "PanelUI-set-up-sync-separator"
   1225    );
   1226 
   1227    let fxaAvatarLabelEl = document.getElementById("fxa-avatar-label");
   1228 
   1229    // Reset FxA/Sync UI elements to default, which is signed out
   1230    cadButtonEl.setAttribute("disabled", true);
   1231    syncNowButtonEl.hidden = true;
   1232    signedInContainer.hidden = true;
   1233    fxaMenuAccountButtonEl.classList.remove("subviewbutton-nav");
   1234    fxaMenuAccountButtonEl.removeAttribute("closemenu");
   1235    menuHeaderDescriptionEl.hidden = false;
   1236 
   1237    // Expanded sign in copy experiment is only for signed out users
   1238    // so if a text variant has been provided then we show the expanded label
   1239    // otherwise it'll be the default avatar icon
   1240    // fxaToolbarMenuButton can be null in certain testing scenarios
   1241    if (fxaToolbarMenuButton) {
   1242      if (
   1243        state.status === UIState.STATUS_NOT_CONFIGURED &&
   1244        expandedSignInCopy
   1245      ) {
   1246        fxaAvatarLabelEl.setAttribute(
   1247          "value",
   1248          this.fluentStrings.formatValueSync(expandedSignInCopy)
   1249        );
   1250        fxaAvatarLabelEl.removeAttribute("hidden");
   1251        fxaToolbarMenuButton.setAttribute("data-l10n-id", "fxa-avatar-tooltip");
   1252        fxaToolbarMenuButton.classList.add("avatar-button-background");
   1253      } else {
   1254        // Either signed in, or experiment not enabled
   1255        fxaToolbarMenuButton.setAttribute(
   1256          "data-l10n-id",
   1257          "toolbar-button-account"
   1258        );
   1259        fxaToolbarMenuButton.classList.remove("avatar-button-background");
   1260        fxaAvatarLabelEl.hidden = true;
   1261      }
   1262    }
   1263 
   1264    // The Firefox Account toolbar currently handles 3 different states for
   1265    // users. The default `not_configured` state shows an empty avatar, `unverified`
   1266    // state shows an avatar with an email icon, `login-failed` state shows an avatar
   1267    // with a danger icon and the `verified` state will show the users
   1268    // custom profile image or a filled avatar.
   1269    let stateValue = "not_configured";
   1270    let headerTitleL10nId;
   1271    let headerDescription;
   1272 
   1273    switch (state.status) {
   1274      case UIState.STATUS_NOT_CONFIGURED:
   1275        mainWindowEl.style.removeProperty("--avatar-image-url");
   1276        headerTitleL10nId = this.FXA_CTA_MENU_ENABLED
   1277          ? "synced-tabs-fxa-sign-in"
   1278          : "appmenuitem-sign-in-account";
   1279        headerDescription = this.fluentStrings.formatValueSync(
   1280          this.FXA_CTA_MENU_ENABLED
   1281            ? "fxa-menu-sync-description"
   1282            : "appmenu-fxa-signed-in-label"
   1283        );
   1284        if (this.FXA_CTA_MENU_ENABLED) {
   1285          const ctaCopy = this.getMenuCtaCopy(NimbusFeatures.fxaAvatarMenuItem);
   1286          if (ctaCopy) {
   1287            headerTitleL10nId = ctaCopy.headerTitleL10nId;
   1288            headerDescription = ctaCopy.headerDescription;
   1289          }
   1290        }
   1291 
   1292        // Reposition profiles elements
   1293        emptyProfilesButton.remove();
   1294        profilesButton.remove();
   1295        profilesSeparator.remove();
   1296 
   1297        profilesSeparator.hidden = true;
   1298 
   1299        signedInContainer.after(profilesSeparator);
   1300        signedInContainer.after(profilesButton);
   1301        signedInContainer.after(emptyProfilesButton);
   1302 
   1303        break;
   1304 
   1305      case UIState.STATUS_LOGIN_FAILED:
   1306        stateValue = "login-failed";
   1307        headerTitleL10nId = "account-disconnected2";
   1308        headerDescription = state.displayName || state.email;
   1309        mainWindowEl.style.removeProperty("--avatar-image-url");
   1310        break;
   1311 
   1312      case UIState.STATUS_NOT_VERIFIED:
   1313        stateValue = "unverified";
   1314        headerTitleL10nId = "account-finish-account-setup";
   1315        headerDescription = state.displayName || state.email;
   1316        break;
   1317 
   1318      case UIState.STATUS_SIGNED_IN:
   1319        stateValue = "signedin";
   1320        headerTitleL10nId = "appmenuitem-fxa-manage-account";
   1321        headerDescription = state.displayName || state.email;
   1322        this.updateAvatarURL(
   1323          mainWindowEl,
   1324          state.avatarURL,
   1325          state.avatarIsDefault
   1326        );
   1327        signedInContainer.hidden = false;
   1328        cadButtonEl.removeAttribute("disabled");
   1329 
   1330        if (state.syncEnabled) {
   1331          // Always show sync now and connect another device button when sync is enabled
   1332          syncNowButtonEl.removeAttribute("hidden");
   1333          cadButtonEl.removeAttribute("hidden");
   1334          syncSetupEl.setAttribute("hidden", "true");
   1335        } else {
   1336          if (this._shouldShowSyncOffIndicator()) {
   1337            fxaToolbarMenuButton?.setAttribute("badge-status", "sync-disabled");
   1338          }
   1339          syncSetupEl.removeAttribute("hidden");
   1340        }
   1341 
   1342        if (state.hasSyncKeys) {
   1343          cadButtonEl.removeAttribute("hidden");
   1344          syncSetupSeparator.removeAttribute("hidden");
   1345        } else {
   1346          cadButtonEl.setAttribute("hidden", "true");
   1347          syncSetupSeparator.setAttribute("hidden", "true");
   1348        }
   1349 
   1350        // Reposition profiles elements
   1351        emptyProfilesButton.remove();
   1352        profilesButton.remove();
   1353        profilesSeparator.remove();
   1354 
   1355        profilesSeparator.hidden = false;
   1356 
   1357        fxaMenuAccountButtonEl.after(profilesSeparator);
   1358        fxaMenuAccountButtonEl.after(profilesButton);
   1359        fxaMenuAccountButtonEl.after(emptyProfilesButton);
   1360 
   1361        break;
   1362 
   1363      default:
   1364        headerTitleL10nId = this.FXA_CTA_MENU_ENABLED
   1365          ? "synced-tabs-fxa-sign-in"
   1366          : "appmenuitem-sign-in-account";
   1367        headerDescription = this.fluentStrings.formatValueSync(
   1368          "fxa-menu-turn-on-sync-default"
   1369        );
   1370        break;
   1371    }
   1372 
   1373    // Update UI elements with determined values
   1374    mainWindowEl.setAttribute("fxastatus", stateValue);
   1375    menuHeaderTitleEl.value =
   1376      this.fluentStrings.formatValueSync(headerTitleL10nId);
   1377    // If we description is empty, we hide it
   1378    menuHeaderDescriptionEl.hidden = !headerDescription;
   1379    menuHeaderDescriptionEl.value = headerDescription;
   1380    // We remove the data-l10n-id attribute here to prevent the node's value
   1381    // attribute from being overwritten by Fluent when the panel is moved
   1382    // around in the DOM.
   1383    menuHeaderTitleEl.removeAttribute("data-l10n-id");
   1384    menuHeaderDescriptionEl.removeAttribute("data-l10n-id");
   1385  },
   1386 
   1387  updateAvatarURL(mainWindowEl, avatarURL, avatarIsDefault) {
   1388    if (avatarURL && !avatarIsDefault) {
   1389      const bgImage = `url("${avatarURL}")`;
   1390      const img = new Image();
   1391      img.onload = () => {
   1392        mainWindowEl.style.setProperty("--avatar-image-url", bgImage);
   1393      };
   1394      img.onerror = () => {
   1395        mainWindowEl.style.removeProperty("--avatar-image-url");
   1396      };
   1397      img.src = avatarURL;
   1398    } else {
   1399      mainWindowEl.style.removeProperty("--avatar-image-url");
   1400    }
   1401  },
   1402 
   1403  enableSendTabIfValidTab() {
   1404    // All tabs selected must be sendable for the Send Tab button to be enabled
   1405    // on the FxA menu.
   1406    let canSendAllURIs = gBrowser.selectedTabs.every(
   1407      t => !!BrowserUtils.getShareableURL(t.linkedBrowser.currentURI)
   1408    );
   1409 
   1410    for (const id of [
   1411      "PanelUI-fxa-menu-sendtab-button",
   1412      "PanelUI-fxa-menu-sendtab-separator",
   1413    ]) {
   1414      PanelMultiView.getViewNode(document, id).hidden = !canSendAllURIs;
   1415    }
   1416  },
   1417 
   1418  // This is mis-named - it can be used to record any FxA UI telemetry, whether from
   1419  // the toolbar or not. The required `sourceElement` param is enough to help us know
   1420  // how to record the interaction.
   1421  emitFxaToolbarTelemetry(type, sourceElement) {
   1422    if (UIState.isReady()) {
   1423      const state = UIState.get();
   1424      const hasAvatar = state.avatarURL && !state.avatarIsDefault;
   1425      let extraOptions = {
   1426        fxa_status: state.status,
   1427        fxa_avatar: hasAvatar ? "true" : "false",
   1428        fxa_sync_on: state.syncEnabled,
   1429      };
   1430 
   1431      let eventName = this._getEntryPointForElement(sourceElement);
   1432      let category = "";
   1433      if (eventName == "fxa_avatar_menu") {
   1434        category = "fxaAvatarMenu";
   1435      } else if (eventName == "fxa_app_menu") {
   1436        category = "fxaAppMenu";
   1437      } else {
   1438        return;
   1439      }
   1440      Glean[category][
   1441        "click" +
   1442          type
   1443            .split("_")
   1444            .map(word => word[0].toUpperCase() + word.slice(1))
   1445            .join("")
   1446      ]?.record(extraOptions);
   1447    }
   1448  },
   1449 
   1450  updatePanelPopup({ email, displayName, status }) {
   1451    const appMenuStatus = PanelMultiView.getViewNode(
   1452      document,
   1453      "appMenu-fxa-status2"
   1454    );
   1455    const appMenuLabel = PanelMultiView.getViewNode(
   1456      document,
   1457      "appMenu-fxa-label2"
   1458    );
   1459    const appMenuHeaderText = PanelMultiView.getViewNode(
   1460      document,
   1461      "appMenu-fxa-text"
   1462    );
   1463    const appMenuHeaderTitle = PanelMultiView.getViewNode(
   1464      document,
   1465      "appMenu-header-title"
   1466    );
   1467    const appMenuHeaderDescription = PanelMultiView.getViewNode(
   1468      document,
   1469      "appMenu-header-description"
   1470    );
   1471    const fxaPanelView = PanelMultiView.getViewNode(document, "PanelUI-fxa");
   1472 
   1473    let defaultLabel = this.fluentStrings.formatValueSync(
   1474      "appmenu-fxa-signed-in-label"
   1475    );
   1476    // Reset the status bar to its original state.
   1477    appMenuLabel.setAttribute("label", defaultLabel);
   1478    appMenuLabel.removeAttribute("aria-labelledby");
   1479    appMenuStatus.removeAttribute("fxastatus");
   1480 
   1481    if (status == UIState.STATUS_NOT_CONFIGURED) {
   1482      appMenuHeaderText.hidden = false;
   1483      appMenuStatus.classList.add("toolbaritem-combined-buttons");
   1484      appMenuLabel.classList.remove("subviewbutton-nav");
   1485      appMenuHeaderTitle.hidden = true;
   1486      appMenuHeaderDescription.value = defaultLabel;
   1487      return;
   1488    }
   1489    appMenuLabel.classList.remove("subviewbutton-nav");
   1490 
   1491    appMenuHeaderText.hidden = true;
   1492    appMenuStatus.classList.remove("toolbaritem-combined-buttons");
   1493 
   1494    // While we prefer the display name in most case, in some strings
   1495    // where the context is something like "Verify %s", the email
   1496    // is used even when there's a display name.
   1497    if (status == UIState.STATUS_LOGIN_FAILED) {
   1498      const [tooltipDescription, errorLabel] =
   1499        this.fluentStrings.formatValuesSync([
   1500          { id: "account-reconnect", args: { email } },
   1501          { id: "account-disconnected2" },
   1502        ]);
   1503      appMenuStatus.setAttribute("fxastatus", "login-failed");
   1504      appMenuStatus.setAttribute("tooltiptext", tooltipDescription);
   1505      appMenuLabel.classList.add("subviewbutton-nav");
   1506      appMenuHeaderTitle.hidden = false;
   1507      appMenuHeaderTitle.value = errorLabel;
   1508      appMenuHeaderDescription.value = displayName || email;
   1509 
   1510      appMenuLabel.removeAttribute("label");
   1511      appMenuLabel.setAttribute(
   1512        "aria-labelledby",
   1513        `${appMenuHeaderTitle.id},${appMenuHeaderDescription.id}`
   1514      );
   1515      return;
   1516    } else if (status == UIState.STATUS_NOT_VERIFIED) {
   1517      const [tooltipDescription, unverifiedLabel] =
   1518        this.fluentStrings.formatValuesSync([
   1519          { id: "account-verify", args: { email } },
   1520          { id: "account-finish-account-setup" },
   1521        ]);
   1522      appMenuStatus.setAttribute("fxastatus", "unverified");
   1523      appMenuStatus.setAttribute("tooltiptext", tooltipDescription);
   1524      appMenuLabel.classList.add("subviewbutton-nav");
   1525      appMenuHeaderTitle.hidden = false;
   1526      appMenuHeaderTitle.value = unverifiedLabel;
   1527      appMenuHeaderDescription.value = email;
   1528 
   1529      appMenuLabel.removeAttribute("label");
   1530      appMenuLabel.setAttribute(
   1531        "aria-labelledby",
   1532        `${appMenuHeaderTitle.id},${appMenuHeaderDescription.id}`
   1533      );
   1534      return;
   1535    }
   1536 
   1537    appMenuHeaderTitle.hidden = true;
   1538    appMenuHeaderDescription.value = displayName || email;
   1539    appMenuStatus.setAttribute("fxastatus", "signedin");
   1540    appMenuLabel.setAttribute("label", displayName || email);
   1541    appMenuLabel.classList.add("subviewbutton-nav");
   1542    fxaPanelView.setAttribute(
   1543      "title",
   1544      this.fluentStrings.formatValueSync("appmenu-account-header")
   1545    );
   1546    appMenuStatus.removeAttribute("tooltiptext");
   1547  },
   1548 
   1549  updateState(state) {
   1550    for (let [shown, menuId, boxId] of [
   1551      [
   1552        state.status == UIState.STATUS_NOT_CONFIGURED,
   1553        "sync-setup",
   1554        "PanelUI-remotetabs-setupsync",
   1555      ],
   1556      [
   1557        state.status == UIState.STATUS_SIGNED_IN && !state.syncEnabled,
   1558        "sync-enable",
   1559        "PanelUI-remotetabs-syncdisabled",
   1560      ],
   1561      [
   1562        state.status == UIState.STATUS_LOGIN_FAILED,
   1563        "sync-reauthitem",
   1564        "PanelUI-remotetabs-reauthsync",
   1565      ],
   1566      [
   1567        state.status == UIState.STATUS_NOT_VERIFIED,
   1568        "sync-unverifieditem",
   1569        "PanelUI-remotetabs-unverified",
   1570      ],
   1571      [
   1572        state.status == UIState.STATUS_SIGNED_IN && state.syncEnabled,
   1573        "sync-syncnowitem",
   1574        "PanelUI-remotetabs-main",
   1575      ],
   1576    ]) {
   1577      document.getElementById(menuId).hidden = PanelMultiView.getViewNode(
   1578        document,
   1579        boxId
   1580      ).hidden = !shown;
   1581    }
   1582  },
   1583 
   1584  updateSyncStatus(state) {
   1585    let syncNow =
   1586      document.querySelector(".syncNowBtn") ||
   1587      document
   1588        .getElementById("appMenu-viewCache")
   1589        .content.querySelector(".syncNowBtn");
   1590    const syncingUI = syncNow.getAttribute("syncstatus") == "active";
   1591    if (state.syncing != syncingUI) {
   1592      // Do we need to update the UI?
   1593      state.syncing ? this.onActivityStart() : this.onActivityStop();
   1594    }
   1595  },
   1596 
   1597  async openSignInAgainPage(entryPoint) {
   1598    if (!(await FxAccounts.canConnectAccount())) {
   1599      return;
   1600    }
   1601    const url = await FxAccounts.config.promiseConnectAccountURI(entryPoint);
   1602    switchToTabHavingURI(url, true, {
   1603      replaceQueryString: true,
   1604      triggeringPrincipal: Services.scriptSecurityManager.getSystemPrincipal(),
   1605    });
   1606  },
   1607 
   1608  async openDevicesManagementPage(entryPoint) {
   1609    let url = await FxAccounts.config.promiseManageDevicesURI(entryPoint);
   1610    switchToTabHavingURI(url, true, {
   1611      replaceQueryString: true,
   1612      triggeringPrincipal: Services.scriptSecurityManager.getSystemPrincipal(),
   1613    });
   1614  },
   1615 
   1616  async openConnectAnotherDevice(entryPoint) {
   1617    const url = await FxAccounts.config.promiseConnectDeviceURI(entryPoint);
   1618    openTrustedLinkIn(url, "tab");
   1619  },
   1620 
   1621  async clickOpenConnectAnotherDevice(sourceElement) {
   1622    this.emitFxaToolbarTelemetry("cad", sourceElement);
   1623    let entryPoint = this._getEntryPointForElement(sourceElement);
   1624    this.openConnectAnotherDevice(entryPoint);
   1625  },
   1626 
   1627  openSendToDevicePromo() {
   1628    const url = Services.urlFormatter.formatURLPref(
   1629      "identity.sendtabpromo.url"
   1630    );
   1631    switchToTabHavingURI(url, true, { replaceQueryString: true });
   1632  },
   1633 
   1634  async clickFxAMenuHeaderButton(sourceElement) {
   1635    // Depending on the current logged in state of a user,
   1636    // clicking the FxA header will either open
   1637    // a sign-in page, account management page, or sync
   1638    // preferences page.
   1639    const { status } = UIState.get();
   1640    switch (status) {
   1641      case UIState.STATUS_NOT_CONFIGURED:
   1642        this.openFxAEmailFirstPageFromFxaMenu(sourceElement);
   1643        break;
   1644      case UIState.STATUS_LOGIN_FAILED:
   1645        this.openPrefsFromFxaMenu("sync_settings", sourceElement);
   1646        break;
   1647      case UIState.STATUS_NOT_VERIFIED:
   1648        this.openFxAEmailFirstPage("fxa_app_menu_reverify");
   1649        break;
   1650      case UIState.STATUS_SIGNED_IN:
   1651        this._openFxAManagePageFromElement(sourceElement);
   1652    }
   1653  },
   1654 
   1655  // Gets the telemetry "entry point" we should use for a given UI element.
   1656  // This entry-point is recorded in both client telemetry (typically called the "object")
   1657  // and where applicable, also communicated to the server for server telemetry via a URL query param.
   1658  //
   1659  // It inspects the parent elements to determine if the element is within one of our "well known"
   1660  // UI groups, in which case it will return a string for that group (eg, "fxa_app_menu", "fxa_toolbar_button").
   1661  // Otherwise (eg, the item might be directly on the context menu), it will return "fxa_discoverability_native".
   1662  _getEntryPointForElement(sourceElement) {
   1663    // Note that when an element is in either the app menu or the toolbar button menu,
   1664    // in both cases it *will* have a parent with ID "PanelUI-fxa-menu". But when
   1665    // in the app menu, it will also have a grand-parent with ID "appMenu-popup".
   1666    // So we must check for that outer grandparent first.
   1667    const appMenuPanel = document.getElementById("appMenu-popup");
   1668    if (appMenuPanel.contains(sourceElement)) {
   1669      return "fxa_app_menu";
   1670    }
   1671    // If it *is* the toolbar button...
   1672    if (sourceElement.id == "fxa-toolbar-menu-button") {
   1673      return "fxa_avatar_menu";
   1674    }
   1675    // ... or is in the panel shown by that button.
   1676    const fxaMenu = document.getElementById("PanelUI-fxa-menu");
   1677    if (fxaMenu && fxaMenu.contains(sourceElement)) {
   1678      return "fxa_avatar_menu";
   1679    }
   1680    return "fxa_discoverability_native";
   1681  },
   1682 
   1683  async openFxAEmailFirstPage(entryPoint, extraParams = {}) {
   1684    if (!(await FxAccounts.canConnectAccount())) {
   1685      return;
   1686    }
   1687    const url = await FxAccounts.config.promiseConnectAccountURI(
   1688      entryPoint,
   1689      extraParams
   1690    );
   1691    switchToTabHavingURI(url, true, { replaceQueryString: true });
   1692  },
   1693 
   1694  async openFxAEmailFirstPageFromFxaMenu(sourceElement, extraParams = {}) {
   1695    this.emitFxaToolbarTelemetry("login", sourceElement);
   1696    this.openFxAEmailFirstPage(
   1697      this._getEntryPointForElement(sourceElement),
   1698      extraParams
   1699    );
   1700  },
   1701 
   1702  async openFxAManagePage(entryPoint) {
   1703    const url = await FxAccounts.config.promiseManageURI(entryPoint);
   1704    switchToTabHavingURI(url, true, { replaceQueryString: true });
   1705  },
   1706 
   1707  async _openFxAManagePageFromElement(sourceElement) {
   1708    this.emitFxaToolbarTelemetry("account_settings", sourceElement);
   1709    this.openFxAManagePage(this._getEntryPointForElement(sourceElement));
   1710  },
   1711 
   1712  // Returns true if we managed to send the tab to any targets, false otherwise.
   1713  async sendTabToDevice(url, targets, title) {
   1714    const fxaCommandsDevices = [];
   1715    for (const target of targets) {
   1716      if (fxAccounts.commands.sendTab.isDeviceCompatible(target)) {
   1717        fxaCommandsDevices.push(target);
   1718      } else {
   1719        this.log.error(`Target ${target.id} unsuitable for send tab.`);
   1720      }
   1721    }
   1722    // If a primary-password is enabled then it must be unlocked so FxA can get
   1723    // the encryption keys from the login manager. (If we end up using the "sync"
   1724    // fallback that would end up prompting by itself, but the FxA command route
   1725    // will not) - so force that here.
   1726    let cryptoSDR = Cc["@mozilla.org/login-manager/crypto/SDR;1"].getService(
   1727      Ci.nsILoginManagerCrypto
   1728    );
   1729    if (!cryptoSDR.isLoggedIn) {
   1730      if (cryptoSDR.uiBusy) {
   1731        this.log.info("Master password UI is busy - not sending the tabs");
   1732        return false;
   1733      }
   1734      try {
   1735        cryptoSDR.encrypt("bacon"); // forces the mp prompt.
   1736      } catch (e) {
   1737        this.log.info(
   1738          "Master password remains unlocked - not sending the tabs"
   1739        );
   1740        return false;
   1741      }
   1742    }
   1743    let numFailed = 0;
   1744    if (fxaCommandsDevices.length) {
   1745      this.log.info(
   1746        `Sending a tab to ${fxaCommandsDevices
   1747          .map(d => d.id)
   1748          .join(", ")} using FxA commands.`
   1749      );
   1750      const report = await fxAccounts.commands.sendTab.send(
   1751        fxaCommandsDevices,
   1752        { url, title }
   1753      );
   1754      for (let { device, error } of report.failed) {
   1755        this.log.error(
   1756          `Failed to send a tab with FxA commands for ${device.id}.`,
   1757          error
   1758        );
   1759        numFailed++;
   1760      }
   1761    }
   1762    return numFailed < targets.length; // Good enough.
   1763  },
   1764 
   1765  populateSendTabToDevicesMenu(
   1766    devicesPopup,
   1767    uri,
   1768    title,
   1769    multiselected,
   1770    createDeviceNodeFn,
   1771    isFxaMenu = false
   1772  ) {
   1773    uri = BrowserUtils.getShareableURL(uri);
   1774    if (!uri) {
   1775      // log an error as everyone should have already checked this.
   1776      this.log.error("Ignoring request to share a non-sharable URL");
   1777      return;
   1778    }
   1779    if (!createDeviceNodeFn) {
   1780      createDeviceNodeFn = (targetId, name) => {
   1781        let eltName = name ? "menuitem" : "menuseparator";
   1782        return document.createXULElement(eltName);
   1783      };
   1784    }
   1785 
   1786    // remove existing menu items
   1787    for (let i = devicesPopup.children.length - 1; i >= 0; --i) {
   1788      let child = devicesPopup.children[i];
   1789      if (child.classList.contains("sync-menuitem")) {
   1790        child.remove();
   1791      }
   1792    }
   1793 
   1794    if (gSync.sendTabConfiguredAndLoading) {
   1795      // We can only be in this case in the page action menu.
   1796      return;
   1797    }
   1798 
   1799    const fragment = document.createDocumentFragment();
   1800 
   1801    const state = UIState.get();
   1802    if (state.status == UIState.STATUS_SIGNED_IN) {
   1803      const targets = this.getSendTabTargets();
   1804      if (targets.length) {
   1805        this._appendSendTabDeviceList(
   1806          targets,
   1807          fragment,
   1808          createDeviceNodeFn,
   1809          uri.spec,
   1810          title,
   1811          multiselected,
   1812          isFxaMenu
   1813        );
   1814      } else {
   1815        this._appendSendTabSingleDevice(fragment, createDeviceNodeFn);
   1816      }
   1817    } else if (
   1818      state.status == UIState.STATUS_NOT_VERIFIED ||
   1819      state.status == UIState.STATUS_LOGIN_FAILED
   1820    ) {
   1821      this._appendSendTabVerify(fragment, createDeviceNodeFn);
   1822    } else {
   1823      // The only status not handled yet is STATUS_NOT_CONFIGURED, and
   1824      // when we're in that state, none of the menus that call
   1825      // populateSendTabToDevicesMenu are available, so entering this
   1826      // state is unexpected.
   1827      throw new Error(
   1828        "Called populateSendTabToDevicesMenu when in STATUS_NOT_CONFIGURED " +
   1829          "state."
   1830      );
   1831    }
   1832 
   1833    devicesPopup.appendChild(fragment);
   1834  },
   1835 
   1836  _appendSendTabDeviceList(
   1837    targets,
   1838    fragment,
   1839    createDeviceNodeFn,
   1840    url,
   1841    title,
   1842    multiselected,
   1843    isFxaMenu = false
   1844  ) {
   1845    let tabsToSend = multiselected
   1846      ? gBrowser.selectedTabs.map(t => {
   1847          return {
   1848            url: t.linkedBrowser.currentURI.spec,
   1849            title: t.linkedBrowser.contentTitle,
   1850          };
   1851        })
   1852      : [{ url, title }];
   1853 
   1854    const send = to => {
   1855      Promise.all(
   1856        tabsToSend.map(t =>
   1857          // sendTabToDevice does not reject.
   1858          this.sendTabToDevice(t.url, to, t.title)
   1859        )
   1860      ).then(results => {
   1861        // Show the Sent! confirmation if any of the sends succeeded.
   1862        if (results.includes(true)) {
   1863          // FxA button could be hidden with CSS since the user is logged out,
   1864          // although it seems likely this would only happen in testing...
   1865          let fxastatus = document.documentElement.getAttribute("fxastatus");
   1866          let anchorNode =
   1867            (fxastatus &&
   1868              fxastatus != "not_configured" &&
   1869              document.getElementById("fxa-toolbar-menu-button")?.parentNode
   1870                ?.id != "widget-overflow-list" &&
   1871              document.getElementById("fxa-toolbar-menu-button")) ||
   1872            document.getElementById("PanelUI-menu-button");
   1873          ConfirmationHint.show(anchorNode, "confirmation-hint-send-to-device");
   1874        }
   1875        fxAccounts.flushLogFile();
   1876      });
   1877    };
   1878    const onSendAllCommand = () => {
   1879      send(targets);
   1880    };
   1881    const onTargetDeviceCommand = event => {
   1882      const targetId = event.target.getAttribute("clientId");
   1883      const target = targets.find(t => t.id == targetId);
   1884      send([target]);
   1885    };
   1886 
   1887    function addTargetDevice(targetId, name, targetType, lastModified) {
   1888      const targetDevice = createDeviceNodeFn(
   1889        targetId,
   1890        name,
   1891        targetType,
   1892        lastModified
   1893      );
   1894      targetDevice.addEventListener(
   1895        "command",
   1896        targetId ? onTargetDeviceCommand : onSendAllCommand,
   1897        true
   1898      );
   1899      targetDevice.classList.add("sync-menuitem", "sendtab-target");
   1900      targetDevice.setAttribute("clientId", targetId);
   1901      targetDevice.setAttribute("clientType", targetType);
   1902      targetDevice.setAttribute("label", name);
   1903      fragment.appendChild(targetDevice);
   1904    }
   1905 
   1906    for (let target of targets) {
   1907      let type, lastModified;
   1908      if (target.clientRecord) {
   1909        type = Weave.Service.clientsEngine.getClientType(
   1910          target.clientRecord.id
   1911        );
   1912        lastModified = new Date(target.clientRecord.serverLastModified * 1000);
   1913      } else {
   1914        // For phones, FxA uses "mobile" and Sync clients uses "phone".
   1915        type = target.type == "mobile" ? "phone" : target.type;
   1916        lastModified = target.lastAccessTime
   1917          ? new Date(target.lastAccessTime)
   1918          : null;
   1919      }
   1920      addTargetDevice(target.id, target.name, type, lastModified);
   1921    }
   1922 
   1923    if (targets.length > 1) {
   1924      // "Send to All Devices" menu item
   1925      const separator = createDeviceNodeFn();
   1926      separator.classList.add("sync-menuitem");
   1927      fragment.appendChild(separator);
   1928      const [allDevicesLabel, manageDevicesLabel] =
   1929        this.fluentStrings.formatValuesSync(
   1930          isFxaMenu
   1931            ? ["account-send-to-all-devices", "account-manage-devices"]
   1932            : [
   1933                "account-send-to-all-devices-titlecase",
   1934                "account-manage-devices-titlecase",
   1935              ]
   1936        );
   1937      addTargetDevice("", allDevicesLabel, "");
   1938 
   1939      // "Manage devices" menu item
   1940      // We piggyback on the createDeviceNodeFn implementation,
   1941      // it's a big disgusting.
   1942      const targetDevice = createDeviceNodeFn(
   1943        null,
   1944        manageDevicesLabel,
   1945        null,
   1946        null
   1947      );
   1948      targetDevice.addEventListener(
   1949        "command",
   1950        () => gSync.openDevicesManagementPage("sendtab"),
   1951        true
   1952      );
   1953      targetDevice.classList.add("sync-menuitem", "sendtab-target");
   1954      targetDevice.setAttribute("label", manageDevicesLabel);
   1955      fragment.appendChild(targetDevice);
   1956    }
   1957  },
   1958 
   1959  _appendSendTabSingleDevice(fragment, createDeviceNodeFn) {
   1960    const [noDevices, learnMore, connectDevice] =
   1961      this.fluentStrings.formatValuesSync([
   1962        "account-send-tab-to-device-singledevice-status",
   1963        "account-send-tab-to-device-singledevice-learnmore",
   1964        "account-send-tab-to-device-connectdevice",
   1965      ]);
   1966    const actions = [
   1967      {
   1968        label: connectDevice,
   1969        command: () => this.openConnectAnotherDevice("sendtab"),
   1970      },
   1971      { label: learnMore, command: () => this.openSendToDevicePromo() },
   1972    ];
   1973    this._appendSendTabInfoItems(
   1974      fragment,
   1975      createDeviceNodeFn,
   1976      noDevices,
   1977      actions
   1978    );
   1979  },
   1980 
   1981  _appendSendTabVerify(fragment, createDeviceNodeFn) {
   1982    const [notVerified, verifyAccount] = this.fluentStrings.formatValuesSync([
   1983      "account-send-tab-to-device-verify-status",
   1984      "account-send-tab-to-device-verify",
   1985    ]);
   1986    const actions = [
   1987      { label: verifyAccount, command: () => this.openPrefs("sendtab") },
   1988    ];
   1989    this._appendSendTabInfoItems(
   1990      fragment,
   1991      createDeviceNodeFn,
   1992      notVerified,
   1993      actions
   1994    );
   1995  },
   1996 
   1997  _appendSendTabInfoItems(fragment, createDeviceNodeFn, statusLabel, actions) {
   1998    const status = createDeviceNodeFn(null, statusLabel, null);
   1999    status.setAttribute("label", statusLabel);
   2000    status.setAttribute("disabled", true);
   2001    status.classList.add("sync-menuitem");
   2002    fragment.appendChild(status);
   2003 
   2004    const separator = createDeviceNodeFn(null, null, null);
   2005    separator.classList.add("sync-menuitem");
   2006    fragment.appendChild(separator);
   2007 
   2008    for (let { label, command } of actions) {
   2009      const actionItem = createDeviceNodeFn(null, label, null);
   2010      actionItem.addEventListener("command", command, true);
   2011      actionItem.classList.add("sync-menuitem");
   2012      actionItem.setAttribute("label", label);
   2013      fragment.appendChild(actionItem);
   2014    }
   2015  },
   2016 
   2017  // "Send Tab to Device" menu item
   2018  updateTabContextMenu(aPopupMenu, aTargetTab) {
   2019    // We may get here before initialisation. This situation
   2020    // can lead to a empty label for 'Send To Device' Menu.
   2021    this.init();
   2022 
   2023    if (!this.FXA_ENABLED) {
   2024      // These items are hidden in onFxaDisabled(). No need to do anything.
   2025      return;
   2026    }
   2027    let hasASendableURI = false;
   2028    for (let tab of aTargetTab.multiselected
   2029      ? gBrowser.selectedTabs
   2030      : [aTargetTab]) {
   2031      if (BrowserUtils.getShareableURL(tab.linkedBrowser.currentURI)) {
   2032        hasASendableURI = true;
   2033        break;
   2034      }
   2035    }
   2036    const enabled = !this.sendTabConfiguredAndLoading && hasASendableURI;
   2037    const hideItems = this.shouldHideSendContextMenuItems(enabled);
   2038 
   2039    let sendTabsToDevice = document.getElementById("context_sendTabToDevice");
   2040    sendTabsToDevice.disabled = !enabled;
   2041    let sendTabToDeviceSeparator = document.getElementById(
   2042      "context_sendTabToDeviceSeparator"
   2043    );
   2044 
   2045    if (hideItems || !hasASendableURI) {
   2046      sendTabsToDevice.hidden = true;
   2047      sendTabToDeviceSeparator.hidden = true;
   2048    } else {
   2049      let tabCount = aTargetTab.multiselected
   2050        ? gBrowser.multiSelectedTabsCount
   2051        : 1;
   2052      sendTabsToDevice.setAttribute(
   2053        "data-l10n-args",
   2054        JSON.stringify({ tabCount })
   2055      );
   2056      sendTabsToDevice.hidden = false;
   2057      sendTabToDeviceSeparator.hidden = false;
   2058    }
   2059  },
   2060 
   2061  // "Send Page to Device" and "Send Link to Device" menu items
   2062  updateContentContextMenu(contextMenu) {
   2063    if (!this.FXA_ENABLED) {
   2064      // These items are hidden by default. No need to do anything.
   2065      return false;
   2066    }
   2067    // showSendLink and showSendPage are mutually exclusive
   2068    const showSendLink =
   2069      contextMenu.onSaveableLink || contextMenu.onPlainTextLink;
   2070    const showSendPage =
   2071      !showSendLink &&
   2072      !(
   2073        contextMenu.isContentSelected ||
   2074        contextMenu.onImage ||
   2075        contextMenu.onCanvas ||
   2076        contextMenu.onVideo ||
   2077        contextMenu.onAudio ||
   2078        contextMenu.onLink ||
   2079        contextMenu.onTextInput
   2080      );
   2081 
   2082    const targetURI = showSendLink
   2083      ? contextMenu.getLinkURI()
   2084      : contextMenu.browser.currentURI;
   2085    const enabled =
   2086      !this.sendTabConfiguredAndLoading &&
   2087      BrowserUtils.getShareableURL(targetURI);
   2088    const hideItems = this.shouldHideSendContextMenuItems(enabled);
   2089 
   2090    contextMenu.showItem(
   2091      "context-sendpagetodevice",
   2092      !hideItems && showSendPage
   2093    );
   2094    for (const id of [
   2095      "context-sendlinktodevice",
   2096      "context-sep-sendlinktodevice",
   2097    ]) {
   2098      contextMenu.showItem(id, !hideItems && showSendLink);
   2099    }
   2100 
   2101    if (!showSendLink && !showSendPage) {
   2102      return false;
   2103    }
   2104 
   2105    contextMenu.setItemAttr(
   2106      showSendPage ? "context-sendpagetodevice" : "context-sendlinktodevice",
   2107      "disabled",
   2108      !enabled || null
   2109    );
   2110    // return true if context menu items are visible
   2111    return !hideItems && (showSendPage || showSendLink);
   2112  },
   2113 
   2114  // Functions called by observers
   2115  onActivityStart() {
   2116    this._isCurrentlySyncing = true;
   2117    clearTimeout(this._syncAnimationTimer);
   2118    this._syncStartTime = Date.now();
   2119 
   2120    document.querySelectorAll(".syncnow-label").forEach(el => {
   2121      let l10nId = el.getAttribute("syncing-data-l10n-id");
   2122      document.l10n.setAttributes(el, l10nId);
   2123    });
   2124 
   2125    document.querySelectorAll(".syncNowBtn").forEach(el => {
   2126      el.setAttribute("syncstatus", "active");
   2127    });
   2128 
   2129    document
   2130      .getElementById("appMenu-viewCache")
   2131      .content.querySelectorAll(".syncNowBtn")
   2132      .forEach(el => {
   2133        el.setAttribute("syncstatus", "active");
   2134      });
   2135  },
   2136 
   2137  _onActivityStop() {
   2138    this._isCurrentlySyncing = false;
   2139    if (!gBrowser) {
   2140      return;
   2141    }
   2142 
   2143    document.querySelectorAll(".syncnow-label").forEach(el => {
   2144      let l10nId = el.getAttribute("sync-now-data-l10n-id");
   2145      document.l10n.setAttributes(el, l10nId);
   2146    });
   2147 
   2148    document.querySelectorAll(".syncNowBtn").forEach(el => {
   2149      el.removeAttribute("syncstatus");
   2150    });
   2151 
   2152    document
   2153      .getElementById("appMenu-viewCache")
   2154      .content.querySelectorAll(".syncNowBtn")
   2155      .forEach(el => {
   2156        el.removeAttribute("syncstatus");
   2157      });
   2158 
   2159    Services.obs.notifyObservers(null, "test:browser-sync:activity-stop");
   2160  },
   2161 
   2162  onActivityStop() {
   2163    let now = Date.now();
   2164    let syncDuration = now - this._syncStartTime;
   2165 
   2166    if (syncDuration < MIN_STATUS_ANIMATION_DURATION) {
   2167      let animationTime = MIN_STATUS_ANIMATION_DURATION - syncDuration;
   2168      clearTimeout(this._syncAnimationTimer);
   2169      this._syncAnimationTimer = setTimeout(
   2170        () => this._onActivityStop(),
   2171        animationTime
   2172      );
   2173    } else {
   2174      this._onActivityStop();
   2175    }
   2176  },
   2177 
   2178  // Disconnect from sync, and optionally disconnect from the FxA account.
   2179  // Returns true if the disconnection happened (ie, if the user didn't decline
   2180  // when asked to confirm)
   2181  async disconnect({ confirm = true, disconnectAccount = true } = {}) {
   2182    if (disconnectAccount) {
   2183      let deleteLocalData = false;
   2184      if (confirm) {
   2185        let options = await this._confirmFxaAndSyncDisconnect();
   2186        if (!options.userConfirmedDisconnect) {
   2187          return false;
   2188        }
   2189        deleteLocalData = options.deleteLocalData;
   2190      }
   2191      return this._disconnectFxaAndSync(deleteLocalData);
   2192    }
   2193 
   2194    if (confirm && !(await this._confirmSyncDisconnect())) {
   2195      return false;
   2196    }
   2197    return this._disconnectSync();
   2198  },
   2199 
   2200  // Prompt the user to confirm disconnect from FxA and sync with the option
   2201  // to delete syncable data from the device.
   2202  async _confirmFxaAndSyncDisconnect() {
   2203    let options = {
   2204      userConfirmedDisconnect: false,
   2205      deleteLocalData: false,
   2206    };
   2207 
   2208    let [title, body, button, checkbox] = await document.l10n.formatValues([
   2209      { id: "fxa-signout-dialog-title2" },
   2210      { id: "fxa-signout-dialog-body" },
   2211      { id: "fxa-signout-dialog2-button" },
   2212      { id: "fxa-signout-dialog2-checkbox" },
   2213    ]);
   2214 
   2215    const flags =
   2216      Services.prompt.BUTTON_TITLE_IS_STRING * Services.prompt.BUTTON_POS_0 +
   2217      Services.prompt.BUTTON_TITLE_CANCEL * Services.prompt.BUTTON_POS_1;
   2218 
   2219    if (!UIState.get().syncEnabled) {
   2220      checkbox = null;
   2221    }
   2222 
   2223    const result = await Services.prompt.asyncConfirmEx(
   2224      window.browsingContext,
   2225      Services.prompt.MODAL_TYPE_INTERNAL_WINDOW,
   2226      title,
   2227      body,
   2228      flags,
   2229      button,
   2230      null,
   2231      null,
   2232      checkbox,
   2233      false
   2234    );
   2235    const propBag = result.QueryInterface(Ci.nsIPropertyBag2);
   2236    options.userConfirmedDisconnect = propBag.get("buttonNumClicked") == 0;
   2237    options.deleteLocalData = propBag.get("checked");
   2238 
   2239    return options;
   2240  },
   2241 
   2242  async _disconnectFxaAndSync(deleteLocalData) {
   2243    const { SyncDisconnect } = ChromeUtils.importESModule(
   2244      "resource://services-sync/SyncDisconnect.sys.mjs"
   2245    );
   2246    // Record telemetry.
   2247    await fxAccounts.telemetry.recordDisconnection(null, "ui");
   2248 
   2249    await SyncDisconnect.disconnect(deleteLocalData).catch(e => {
   2250      console.error("Failed to disconnect.", e);
   2251    });
   2252 
   2253    // Clear the attached clients list upon successfully disconnecting
   2254    this._attachedClients = null;
   2255 
   2256    return true;
   2257  },
   2258 
   2259  // Prompt the user to confirm disconnect from sync. In this case the data
   2260  // on the device is not deleted.
   2261  async _confirmSyncDisconnect() {
   2262    const [title, body, button] = await document.l10n.formatValues([
   2263      { id: `sync-disconnect-dialog-title2` },
   2264      { id: `sync-disconnect-dialog-body` },
   2265      { id: "sync-disconnect-dialog-button" },
   2266    ]);
   2267 
   2268    const flags =
   2269      Services.prompt.BUTTON_TITLE_IS_STRING * Services.prompt.BUTTON_POS_0 +
   2270      Services.prompt.BUTTON_TITLE_CANCEL * Services.prompt.BUTTON_POS_1;
   2271 
   2272    // buttonPressed will be 0 for disconnect, 1 for cancel.
   2273    const buttonPressed = Services.prompt.confirmEx(
   2274      window,
   2275      title,
   2276      body,
   2277      flags,
   2278      button,
   2279      null,
   2280      null,
   2281      null,
   2282      {}
   2283    );
   2284    return buttonPressed == 0;
   2285  },
   2286 
   2287  async _disconnectSync() {
   2288    await fxAccounts.telemetry.recordDisconnection("sync", "ui");
   2289 
   2290    await Weave.Service.promiseInitialized;
   2291    await Weave.Service.startOver();
   2292 
   2293    return true;
   2294  },
   2295 
   2296  // doSync forces a sync - it *does not* return a promise as it is called
   2297  // via the various UI components.
   2298  doSync() {
   2299    if (!UIState.isReady()) {
   2300      return;
   2301    }
   2302    // Note we don't bother checking if sync is actually enabled - none of the
   2303    // UI which calls this function should be visible in that case.
   2304    const state = UIState.get();
   2305    if (state.status == UIState.STATUS_SIGNED_IN) {
   2306      this.updateSyncStatus({ syncing: true });
   2307      Services.tm.dispatchToMainThread(() => {
   2308        // We are pretty confident that push helps us pick up all FxA commands,
   2309        // but some users might have issues with push, so let's unblock them
   2310        // by fetching the missed FxA commands on manual sync.
   2311        fxAccounts.commands.pollDeviceCommands().catch(e => {
   2312          this.log.error("Fetching missed remote commands failed.", e);
   2313        });
   2314        Weave.Service.sync();
   2315      });
   2316    }
   2317  },
   2318 
   2319  doSyncFromFxaMenu(sourceElement) {
   2320    this.doSync();
   2321    this.emitFxaToolbarTelemetry("sync_now", sourceElement);
   2322  },
   2323 
   2324  openPrefs(entryPoint = "syncbutton", origin = undefined, urlParams = {}) {
   2325    window.openPreferences("paneSync", {
   2326      origin,
   2327      urlParams: { ...urlParams, entrypoint: entryPoint },
   2328    });
   2329  },
   2330 
   2331  openPrefsFromFxaMenu(type, sourceElement) {
   2332    this.emitFxaToolbarTelemetry(type, sourceElement);
   2333    let entryPoint = this._getEntryPointForElement(sourceElement);
   2334    this.openPrefs(entryPoint);
   2335  },
   2336 
   2337  openChooseWhatToSync(type, sourceElement) {
   2338    this.emitFxaToolbarTelemetry(type, sourceElement);
   2339    let entryPoint = this._getEntryPointForElement(sourceElement);
   2340    this.openPrefs(entryPoint, null, { action: "choose-what-to-sync" });
   2341  },
   2342 
   2343  /**
   2344   * Opens the appropriate sync setup flow based on whether the user has sync keys.
   2345   * - If the user has sync keys: opens sync preferences to configure what to sync
   2346   * - If the user doesn't have sync keys (third-party auth): opens FxA to create password
   2347   */
   2348  async openSyncSetup(type, sourceElement, extraParams = {}) {
   2349    this.emitFxaToolbarTelemetry(type, sourceElement);
   2350    const entryPoint = this._getEntryPointForElement(sourceElement);
   2351 
   2352    try {
   2353      // Check if the user has sync keys
   2354      const hasKeys = await fxAccounts.keys.hasKeysForScope(SCOPE_APP_SYNC);
   2355 
   2356      if (hasKeys) {
   2357        // User has keys - go to prefs to configure what to sync
   2358        this.openPrefs(entryPoint, null, { action: "choose-what-to-sync" });
   2359      } else {
   2360        // User doesn't have keys (third-party auth) - go to FxA to create password
   2361        // This will request SCOPE_APP_SYNC so FxA knows to generate sync keys
   2362        if (!(await FxAccounts.canConnectAccount())) {
   2363          return;
   2364        }
   2365        const url = await FxAccounts.config.promiseSetPasswordURI(
   2366          entryPoint,
   2367          extraParams
   2368        );
   2369        switchToTabHavingURI(url, true, { replaceQueryString: true });
   2370      }
   2371    } catch (err) {
   2372      this.log.error("Failed to determine sync setup flow", err);
   2373      // Fall back to opening prefs
   2374      this.openPrefs(entryPoint);
   2375    }
   2376  },
   2377 
   2378  openSyncedTabsPanel() {
   2379    let placement = CustomizableUI.getPlacementOfWidget("sync-button");
   2380    let area = placement?.area;
   2381    let anchor = document.getElementById("sync-button");
   2382    if (area == CustomizableUI.AREA_FIXED_OVERFLOW_PANEL) {
   2383      // The button is in the overflow panel, so we need to show the panel,
   2384      // then show our subview.
   2385      let navbar = document.getElementById(CustomizableUI.AREA_NAVBAR);
   2386      navbar.overflowable.show().then(() => {
   2387        PanelUI.showSubView("PanelUI-remotetabs", anchor);
   2388      }, console.error);
   2389    } else {
   2390      if (
   2391        !anchor?.checkVisibility({ checkVisibilityCSS: true, flush: false })
   2392      ) {
   2393        anchor = document.getElementById("PanelUI-menu-button");
   2394      }
   2395      // It is placed somewhere else - just try and show it.
   2396      PanelUI.showSubView("PanelUI-remotetabs", anchor);
   2397    }
   2398  },
   2399 
   2400  refreshSyncButtonsTooltip() {
   2401    const state = UIState.get();
   2402    this.updateSyncButtonsTooltip(state);
   2403  },
   2404 
   2405  /* Update the tooltip for the sync icon in the main menu and in Synced Tabs.
   2406     If Sync is configured, the tooltip is when the last sync occurred,
   2407     otherwise the tooltip reflects the fact that Sync needs to be
   2408     (re-)configured.
   2409  */
   2410  updateSyncButtonsTooltip(state) {
   2411    // Sync buttons are 1/2 Sync related and 1/2 FxA related
   2412    let l10nId, l10nArgs;
   2413    switch (state.status) {
   2414      case UIState.STATUS_NOT_VERIFIED:
   2415        // "needs verification"
   2416        l10nId = "account-verify";
   2417        l10nArgs = { email: state.email };
   2418        break;
   2419      case UIState.STATUS_LOGIN_FAILED:
   2420        // "need to reconnect/re-enter your password"
   2421        l10nId = "account-reconnect";
   2422        l10nArgs = { email: state.email };
   2423        break;
   2424      case UIState.STATUS_NOT_CONFIGURED:
   2425        // Button is not shown in this state
   2426        break;
   2427      default: {
   2428        // Sync appears configured - format the "last synced at" time.
   2429        let lastSyncDate = this.formatLastSyncDate(state.lastSync);
   2430        if (lastSyncDate) {
   2431          l10nId = "appmenu-fxa-last-sync";
   2432          l10nArgs = { time: lastSyncDate };
   2433        }
   2434      }
   2435    }
   2436    const tooltiptext = l10nId
   2437      ? this.fluentStrings.formatValueSync(l10nId, l10nArgs)
   2438      : null;
   2439 
   2440    let syncNowBtns = [
   2441      "PanelUI-remotetabs-syncnow",
   2442      "PanelUI-fxa-menu-syncnow-button",
   2443    ];
   2444    syncNowBtns.forEach(id => {
   2445      let el = PanelMultiView.getViewNode(document, id);
   2446      if (tooltiptext) {
   2447        el.setAttribute("tooltiptext", tooltiptext);
   2448      } else {
   2449        el.removeAttribute("tooltiptext");
   2450      }
   2451    });
   2452  },
   2453 
   2454  get relativeTimeFormat() {
   2455    delete this.relativeTimeFormat;
   2456    return (this.relativeTimeFormat = new Services.intl.RelativeTimeFormat(
   2457      undefined,
   2458      { style: "long" }
   2459    ));
   2460  },
   2461 
   2462  formatLastSyncDate(date) {
   2463    if (!date) {
   2464      // Date can be null before the first sync!
   2465      return null;
   2466    }
   2467    try {
   2468      let adjustedDate = new Date(Date.now() - 1000);
   2469      let relativeDateStr = this.relativeTimeFormat.formatBestUnit(
   2470        date < adjustedDate ? date : adjustedDate
   2471      );
   2472      return relativeDateStr;
   2473    } catch (ex) {
   2474      // shouldn't happen, but one client having an invalid date shouldn't
   2475      // break the entire feature.
   2476      this.log.warn("failed to format lastSync time", date, ex);
   2477      return null;
   2478    }
   2479  },
   2480 
   2481  onClientsSynced() {
   2482    // Note that this element is only shown if Sync is enabled.
   2483    let element = PanelMultiView.getViewNode(
   2484      document,
   2485      "PanelUI-remotetabs-main"
   2486    );
   2487    if (element) {
   2488      if (Weave.Service.clientsEngine.stats.numClients > 1) {
   2489        element.setAttribute("devices-status", "multi");
   2490      } else {
   2491        element.setAttribute("devices-status", "single");
   2492      }
   2493    }
   2494  },
   2495 
   2496  onFxaDisabled() {
   2497    document.documentElement.setAttribute("fxadisabled", true);
   2498 
   2499    const toHide = [...document.querySelectorAll(".sync-ui-item")];
   2500    for (const item of toHide) {
   2501      item.hidden = true;
   2502    }
   2503  },
   2504 
   2505  /**
   2506   * Checks if the current list of attached clients to the Mozilla account
   2507   * has a service associated with the passed in Id
   2508   *
   2509   *  @param {string} clientId
   2510   *   A known static Id from FxA that identifies the service it's associated with
   2511   *  @returns {boolean}
   2512   *   Returns true/false whether the current account has the associated client
   2513   */
   2514  hasClientForId(clientId) {
   2515    return this._attachedClients?.some(c => !!c.id && c.id === clientId);
   2516  },
   2517 
   2518  updateCTAPanel(anchor) {
   2519    const mainPanelEl = PanelMultiView.getViewNode(
   2520      document,
   2521      "PanelUI-fxa-cta-menu"
   2522    );
   2523 
   2524    // If we're not in the experiment or in the app menu (hamburger)
   2525    // do not show this CTA panel
   2526    if (
   2527      !this.FXA_CTA_MENU_ENABLED ||
   2528      (anchor && anchor.id === "appMenu-fxa-label2")
   2529    ) {
   2530      // If we've previously shown this but got disabled
   2531      // we should ensure we hide the panel
   2532      mainPanelEl.hidden = true;
   2533      return;
   2534    }
   2535 
   2536    // Monitor checks
   2537    let monitorPanelEl = PanelMultiView.getViewNode(
   2538      document,
   2539      "PanelUI-fxa-menu-monitor-button"
   2540    );
   2541    let monitorEnabled = Services.prefs.getBoolPref(
   2542      "identity.fxaccounts.toolbar.pxiToolbarEnabled.monitorEnabled",
   2543      false
   2544    );
   2545    monitorPanelEl.hidden = !monitorEnabled;
   2546 
   2547    // Relay checks
   2548    let relayPanelEl = PanelMultiView.getViewNode(
   2549      document,
   2550      "PanelUI-fxa-menu-relay-button"
   2551    );
   2552    let relayEnabled =
   2553      BrowserUtils.shouldShowPromo(BrowserUtils.PromoType.RELAY) &&
   2554      Services.prefs.getBoolPref(
   2555        "identity.fxaccounts.toolbar.pxiToolbarEnabled.relayEnabled",
   2556        false
   2557      );
   2558    let myServicesRelayPanelEl = PanelMultiView.getViewNode(
   2559      document,
   2560      "PanelUI-services-menu-relay-button"
   2561    );
   2562    let servicesContainerEl = PanelMultiView.getViewNode(
   2563      document,
   2564      "PanelUI-fxa-menu-services"
   2565    );
   2566    if (this.isSignedIn) {
   2567      const hasRelayClient = this.hasClientForId(FX_RELAY_OAUTH_CLIENT_ID);
   2568      relayPanelEl.hidden = hasRelayClient;
   2569      // Right now only relay is under "my services" so if we don't have, we turn it off
   2570      myServicesRelayPanelEl.hidden = !hasRelayClient;
   2571      servicesContainerEl.hidden = !hasRelayClient;
   2572    } else {
   2573      relayPanelEl.hidden = !relayEnabled;
   2574      // We'll never show my services when signed out
   2575      myServicesRelayPanelEl.hidden = true;
   2576      servicesContainerEl.hidden = true;
   2577    }
   2578 
   2579    // VPN checks
   2580    let VpnPanelEl = PanelMultiView.getViewNode(
   2581      document,
   2582      "PanelUI-fxa-menu-vpn-button"
   2583    );
   2584    let vpnEnabled =
   2585      BrowserUtils.shouldShowPromo(BrowserUtils.PromoType.VPN) &&
   2586      Services.prefs.getBoolPref(
   2587        "identity.fxaccounts.toolbar.pxiToolbarEnabled.vpnEnabled",
   2588        false
   2589      );
   2590    VpnPanelEl.hidden = !vpnEnabled;
   2591 
   2592    // We should only the show the separator if we have at least one CTA enabled
   2593    PanelMultiView.getViewNode(document, "PanelUI-products-separator").hidden =
   2594      !monitorEnabled && !relayEnabled && !vpnEnabled;
   2595    mainPanelEl.hidden = false;
   2596  },
   2597 
   2598  async openMonitorLink(sourceElement) {
   2599    this.emitFxaToolbarTelemetry("monitor_cta", sourceElement);
   2600    await this.openCtaLink(
   2601      FX_MONITOR_OAUTH_CLIENT_ID,
   2602      new URL("https://monitor.firefox.com"),
   2603      new URL("https://monitor.firefox.com/user/breaches")
   2604    );
   2605  },
   2606 
   2607  async openRelayLink(sourceElement) {
   2608    this.emitFxaToolbarTelemetry("relay_cta", sourceElement);
   2609    await this.openCtaLink(
   2610      FX_RELAY_OAUTH_CLIENT_ID,
   2611      new URL("https://relay.firefox.com"),
   2612      new URL("https://relay.firefox.com/accounts/profile")
   2613    );
   2614  },
   2615 
   2616  async openVPNLink(sourceElement) {
   2617    this.emitFxaToolbarTelemetry("vpn_cta", sourceElement);
   2618    await this.openCtaLink(
   2619      VPN_OAUTH_CLIENT_ID,
   2620      new URL("https://www.mozilla.org/en-US/products/vpn/"),
   2621      new URL("https://www.mozilla.org/en-US/products/vpn/")
   2622    );
   2623  },
   2624 
   2625  // A generic opening based on
   2626  async openCtaLink(clientId, defaultUrl, signedInUrl) {
   2627    const params = {
   2628      utm_medium: "firefox-desktop",
   2629      utm_source: "toolbar",
   2630      utm_campaign: "discovery",
   2631    };
   2632    const searchParams = new URLSearchParams(params);
   2633 
   2634    if (!this.isSignedIn) {
   2635      // Add the base params + not signed in
   2636      defaultUrl.search = searchParams.toString();
   2637      defaultUrl.searchParams.append("utm_content", "notsignedin");
   2638      this.openLink(defaultUrl);
   2639      PanelUI.hide();
   2640      return;
   2641    }
   2642 
   2643    const url = this.hasClientForId(clientId) ? signedInUrl : defaultUrl;
   2644    // Add base params + signed in
   2645    url.search = searchParams.toString();
   2646    url.searchParams.append("utm_content", "signedIn");
   2647 
   2648    this.openLink(url);
   2649    PanelUI.hide();
   2650  },
   2651 
   2652  /**
   2653   * Returns any experimental copy that we want to try for FxA sign-in CTAs in
   2654   * the event that the user is enrolled in an experiment.
   2655   *
   2656   * The only ctaCopyVariant's that are expected are:
   2657   *
   2658   *  - control
   2659   *  - sync-devices
   2660   *  - backup-data
   2661   *  - backup-sync
   2662   *  - mobile
   2663   *
   2664   * If "control" is set, `null` is returned to indicate default strings,
   2665   * but impressions will still be recorded.
   2666   *
   2667   * @param {NimbusFeature} feature
   2668   *   One of either NimbusFeatures.fxaAppMenuItem or
   2669   *   NimbusFeatures.fxaAvatarMenuItem.
   2670   * @returns {object|string|null}
   2671   *   If feature is NimbusFeatures.fxaAppMenuItem, this will return the Fluent
   2672   *   string ID for the App Menu CTA to appear for users to sign in.
   2673   *
   2674   *   If feature is NimbusFeatures.fxaAvatarMenuItem, this will return an
   2675   *   object with two properties:
   2676   *
   2677   *   headerTitleL10nId (string):
   2678   *     The Fluent ID for the header string for the avatar menu CTA.
   2679   *   headerDescription (string):
   2680   *     The raw string for the description for the avatar menu CTA.
   2681   *
   2682   *   If there is no copy variant being tested, this will return null.
   2683   */
   2684  getMenuCtaCopy(feature) {
   2685    const ctaCopyVariant = feature.getVariable("ctaCopyVariant");
   2686    let headerTitleL10nId;
   2687    let headerDescription;
   2688    switch (ctaCopyVariant) {
   2689      case "sync-devices": {
   2690        if (feature === NimbusFeatures.fxaAppMenuItem) {
   2691          return "fxa-menu-message-sync-devices-collapsed-text";
   2692        }
   2693        headerTitleL10nId = "fxa-menu-message-sync-devices-primary-text";
   2694        headerDescription = this.fluentStrings.formatValueSync(
   2695          "fxa-menu-message-sync-devices-secondary-text"
   2696        );
   2697        break;
   2698      }
   2699      case "backup-data": {
   2700        if (feature === NimbusFeatures.fxaAppMenuItem) {
   2701          return "fxa-menu-message-backup-data-collapsed-text";
   2702        }
   2703        headerTitleL10nId = "fxa-menu-message-backup-data-primary-text";
   2704        headerDescription = this.fluentStrings.formatValueSync(
   2705          "fxa-menu-message-backup-data-secondary-text"
   2706        );
   2707        break;
   2708      }
   2709      case "backup-sync": {
   2710        if (feature === NimbusFeatures.fxaAppMenuItem) {
   2711          return "fxa-menu-message-backup-sync-collapsed-text";
   2712        }
   2713        headerTitleL10nId = "fxa-menu-message-backup-sync-primary-text";
   2714        headerDescription = this.fluentStrings.formatValueSync(
   2715          "fxa-menu-message-backup-sync-secondary-text"
   2716        );
   2717        break;
   2718      }
   2719      case "mobile": {
   2720        if (feature === NimbusFeatures.fxaAppMenuItem) {
   2721          return "fxa-menu-message-mobile-collapsed-text";
   2722        }
   2723        headerTitleL10nId = "fxa-menu-message-mobile-primary-text";
   2724        headerDescription = this.fluentStrings.formatValueSync(
   2725          "fxa-menu-message-mobile-secondary-text"
   2726        );
   2727        break;
   2728      }
   2729      default: {
   2730        return null;
   2731      }
   2732    }
   2733 
   2734    return { headerTitleL10nId, headerDescription };
   2735  },
   2736 
   2737  /**
   2738   * Updates the FxA button to show the right avatar variant in the event that
   2739   * this client is not currently signed into an account.
   2740   *
   2741   * @param {string} variant
   2742   *   One of the string constants for the avatarIconVariant variable on the
   2743   *   fxaButtonVisibility feature.
   2744   */
   2745  applyAvatarIconVariant(variant) {
   2746    const ICON_VARIANTS = ["control", "human-circle", "fox-circle"];
   2747 
   2748    if (!ICON_VARIANTS.includes(variant)) {
   2749      return;
   2750    }
   2751 
   2752    document.documentElement.setAttribute("fxa-avatar-icon-variant", variant);
   2753  },
   2754 
   2755  openLink(url) {
   2756    switchToTabHavingURI(url, true, { replaceQueryString: true });
   2757  },
   2758 
   2759  QueryInterface: ChromeUtils.generateQI([
   2760    "nsIObserver",
   2761    "nsISupportsWeakReference",
   2762  ]),
   2763 };