tor-browser

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

opentabs.mjs (26210B)


      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 import {
      6  classMap,
      7  html,
      8  map,
      9  when,
     10 } from "chrome://global/content/vendor/lit.all.mjs";
     11 import { MozLitElement } from "chrome://global/content/lit-utils.mjs";
     12 import { getLogger, MAX_TABS_FOR_RECENT_BROWSING } from "./helpers.mjs";
     13 import { searchTabList } from "./search-helpers.mjs";
     14 import { ViewPage, ViewPageContent } from "./viewpage.mjs";
     15 // eslint-disable-next-line import/no-unassigned-import
     16 import "chrome://browser/content/firefoxview/opentabs-tab-list.mjs";
     17 
     18 const lazy = {};
     19 
     20 ChromeUtils.defineESModuleGetters(lazy, {
     21  BookmarkList: "resource://gre/modules/BookmarkList.sys.mjs",
     22  BrowserUtils: "resource://gre/modules/BrowserUtils.sys.mjs",
     23  NonPrivateTabs: "resource:///modules/OpenTabs.sys.mjs",
     24  OpenTabsController: "resource:///modules/OpenTabsController.sys.mjs",
     25  getTabsTargetForWindow: "resource:///modules/OpenTabs.sys.mjs",
     26  PrivateBrowsingUtils: "resource://gre/modules/PrivateBrowsingUtils.sys.mjs",
     27  TabMetrics: "moz-src:///browser/components/tabbrowser/TabMetrics.sys.mjs",
     28 });
     29 
     30 ChromeUtils.defineLazyGetter(lazy, "fxAccounts", () => {
     31  return ChromeUtils.importESModule(
     32    "resource://gre/modules/FxAccounts.sys.mjs"
     33  ).getFxAccountsSingleton();
     34 });
     35 
     36 const TOPIC_DEVICESTATE_CHANGED = "firefox-view.devicestate.changed";
     37 const TOPIC_DEVICELIST_UPDATED = "fxaccounts:devicelist_updated";
     38 
     39 /**
     40 * A collection of open tabs grouped by window.
     41 *
     42 * @property {Array<Window>} windows
     43 *   A list of windows with the same privateness
     44 * @property {string} sortOption
     45 *   The sorting order of open tabs:
     46 *   - "recency": Sorted by recent activity. (For recent browsing, this is the only option.)
     47 *   - "tabStripOrder": Match the order in which they appear on the tab strip.
     48 */
     49 class OpenTabsInView extends ViewPage {
     50  static properties = {
     51    ...ViewPage.properties,
     52    windows: { type: Array },
     53    searchQuery: { type: String },
     54    sortOption: { type: String },
     55  };
     56  static queries = {
     57    viewCards: { all: "view-opentabs-card" },
     58    optionsContainer: ".open-tabs-options",
     59    searchTextbox: "moz-input-search",
     60  };
     61 
     62  initialWindowsReady = false;
     63  currentWindow = null;
     64  openTabsTarget = null;
     65 
     66  constructor() {
     67    super();
     68    this._started = false;
     69    this.windows = [];
     70    this.currentWindow = this.getWindow();
     71    if (lazy.PrivateBrowsingUtils.isWindowPrivate(this.currentWindow)) {
     72      this.openTabsTarget = lazy.getTabsTargetForWindow(this.currentWindow);
     73    } else {
     74      this.openTabsTarget = lazy.NonPrivateTabs;
     75    }
     76    this.searchQuery = "";
     77    this.sortOption = this.recentBrowsing
     78      ? "recency"
     79      : Services.prefs.getStringPref(
     80          "browser.tabs.firefox-view.ui-state.opentabs.sort-option",
     81          "recency"
     82        );
     83  }
     84 
     85  start() {
     86    if (this._started) {
     87      return;
     88    }
     89    this._started = true;
     90    this.#setupTabChangeListener();
     91 
     92    // To resolve the race between this component wanting to render all the windows'
     93    // tabs, while those windows are still potentially opening, flip this property
     94    // once the promise resolves and we'll bail out of rendering until then.
     95    this.openTabsTarget.readyWindowsPromise.finally(() => {
     96      this.initialWindowsReady = true;
     97      this._updateWindowList();
     98    });
     99 
    100    for (let card of this.viewCards) {
    101      card.paused = false;
    102      card.viewVisibleCallback?.();
    103    }
    104 
    105    if (this.recentBrowsing) {
    106      this.recentBrowsingElement.addEventListener(
    107        "MozInputSearch:search",
    108        this
    109      );
    110    }
    111 
    112    this.bookmarkList = new lazy.BookmarkList(this.#getAllTabUrls(), () =>
    113      this.viewCards.forEach(card => card.requestUpdate())
    114    );
    115  }
    116 
    117  shouldUpdate(changedProperties) {
    118    if (!this.initialWindowsReady) {
    119      return false;
    120    }
    121    return super.shouldUpdate(changedProperties);
    122  }
    123 
    124  disconnectedCallback() {
    125    super.disconnectedCallback();
    126    this.stop();
    127  }
    128 
    129  stop() {
    130    if (!this._started) {
    131      return;
    132    }
    133    this._started = false;
    134    this.paused = true;
    135 
    136    this.openTabsTarget.removeEventListener("TabChange", this);
    137    this.openTabsTarget.removeEventListener("TabRecencyChange", this);
    138 
    139    for (let card of this.viewCards) {
    140      card.paused = true;
    141      card.viewHiddenCallback?.();
    142    }
    143 
    144    if (this.recentBrowsing) {
    145      this.recentBrowsingElement.removeEventListener(
    146        "MozInputSearch:search",
    147        this
    148      );
    149    }
    150 
    151    this.bookmarkList.removeListeners();
    152  }
    153 
    154  viewVisibleCallback() {
    155    this.start();
    156  }
    157 
    158  viewHiddenCallback() {
    159    this.stop();
    160  }
    161 
    162  #setupTabChangeListener() {
    163    if (this.sortOption === "recency") {
    164      this.openTabsTarget.addEventListener("TabRecencyChange", this);
    165      this.openTabsTarget.removeEventListener("TabChange", this);
    166    } else {
    167      this.openTabsTarget.removeEventListener("TabRecencyChange", this);
    168      this.openTabsTarget.addEventListener("TabChange", this);
    169    }
    170  }
    171 
    172  #getAllTabUrls() {
    173    return this.openTabsTarget
    174      .getAllTabs()
    175      .map(({ linkedBrowser }) => linkedBrowser?.currentURI?.spec)
    176      .filter(Boolean);
    177  }
    178 
    179  render() {
    180    if (this.recentBrowsing) {
    181      return this.getRecentBrowsingTemplate();
    182    }
    183    let currentWindowIndex, currentWindowTabs;
    184    let index = 1;
    185    const otherWindows = [];
    186    this.windows.forEach(win => {
    187      const tabs = this.openTabsTarget.getTabsForWindow(
    188        win,
    189        this.sortOption === "recency"
    190      );
    191      if (win === this.currentWindow) {
    192        currentWindowIndex = index++;
    193        currentWindowTabs = tabs;
    194      } else {
    195        otherWindows.push([index++, tabs, win]);
    196      }
    197    });
    198 
    199    const cardClasses = classMap({
    200      "height-limited": this.windows.length > 3,
    201      "width-limited": this.windows.length > 1,
    202    });
    203    let cardCount;
    204    if (this.windows.length <= 1) {
    205      cardCount = "one";
    206    } else if (this.windows.length === 2) {
    207      cardCount = "two";
    208    } else {
    209      cardCount = "three-or-more";
    210    }
    211    return html`
    212      <link
    213        rel="stylesheet"
    214        href="chrome://browser/content/firefoxview/view-opentabs.css"
    215      />
    216      <link
    217        rel="stylesheet"
    218        href="chrome://browser/content/firefoxview/firefoxview.css"
    219      />
    220      <div class="sticky-container bottom-fade">
    221        <h2 class="page-header" data-l10n-id="firefoxview-opentabs-header"></h2>
    222        <div class="open-tabs-options">
    223          <moz-input-search
    224            data-l10n-id="firefoxview-search-text-box-opentabs"
    225            data-l10n-attrs="placeholder"
    226            @MozInputSearch:search=${this.onSearchQuery}
    227          ></moz-input-search>
    228          <div class="open-tabs-sort-wrapper">
    229            <div class="open-tabs-sort-option">
    230              <input
    231                type="radio"
    232                id="sort-by-recency"
    233                name="open-tabs-sort-option"
    234                value="recency"
    235                ?checked=${this.sortOption === "recency"}
    236                @click=${this.onChangeSortOption}
    237              />
    238              <label
    239                for="sort-by-recency"
    240                data-l10n-id="firefoxview-sort-open-tabs-by-recency-label"
    241              ></label>
    242            </div>
    243            <div class="open-tabs-sort-option">
    244              <input
    245                type="radio"
    246                id="sort-by-order"
    247                name="open-tabs-sort-option"
    248                value="tabStripOrder"
    249                ?checked=${this.sortOption === "tabStripOrder"}
    250                @click=${this.onChangeSortOption}
    251              />
    252              <label
    253                for="sort-by-order"
    254                data-l10n-id="firefoxview-sort-open-tabs-by-order-label"
    255              ></label>
    256            </div>
    257          </div>
    258        </div>
    259      </div>
    260      <div
    261        card-count=${cardCount}
    262        class="view-opentabs-card-container cards-container"
    263      >
    264        ${when(
    265          currentWindowIndex && currentWindowTabs,
    266          () => html`
    267            <view-opentabs-card
    268              class=${cardClasses}
    269              .tabs=${currentWindowTabs}
    270              .paused=${this.paused}
    271              data-inner-id=${this.currentWindow.windowGlobalChild
    272                .innerWindowId}
    273              data-l10n-id="firefoxview-opentabs-current-window-header"
    274              data-l10n-args=${JSON.stringify({
    275                winID: currentWindowIndex,
    276              })}
    277              .searchQuery=${this.searchQuery}
    278              .bookmarkList=${this.bookmarkList}
    279            ></view-opentabs-card>
    280          `
    281        )}
    282        ${map(
    283          otherWindows,
    284          ([winID, tabs, win]) => html`
    285            <view-opentabs-card
    286              class=${cardClasses}
    287              .tabs=${tabs}
    288              .paused=${this.paused}
    289              data-inner-id=${win.windowGlobalChild.innerWindowId}
    290              data-l10n-id="firefoxview-opentabs-window-header"
    291              data-l10n-args=${JSON.stringify({ winID })}
    292              .searchQuery=${this.searchQuery}
    293              .bookmarkList=${this.bookmarkList}
    294            ></view-opentabs-card>
    295          `
    296        )}
    297      </div>
    298    `;
    299  }
    300 
    301  onSearchQuery(e) {
    302    if (!this.recentBrowsing) {
    303      Glean.firefoxviewNext.searchInitiatedSearch.record({
    304        page: "opentabs",
    305      });
    306    }
    307    this.searchQuery = e.detail.query;
    308  }
    309 
    310  onChangeSortOption(e) {
    311    this.sortOption = e.target.value;
    312    this.#setupTabChangeListener();
    313    if (!this.recentBrowsing) {
    314      Services.prefs.setStringPref(
    315        "browser.tabs.firefox-view.ui-state.opentabs.sort-option",
    316        this.sortOption
    317      );
    318    }
    319  }
    320 
    321  /**
    322   * Render a template for the 'Recent browsing' page, which shows a shorter list of
    323   * open tabs in the current window.
    324   *
    325   * @returns {TemplateResult}
    326   *   The recent browsing template.
    327   */
    328  getRecentBrowsingTemplate() {
    329    const tabs = this.openTabsTarget.getRecentTabs();
    330    return html`<view-opentabs-card
    331      .tabs=${tabs}
    332      .recentBrowsing=${true}
    333      .paused=${this.paused}
    334      .searchQuery=${this.searchQuery}
    335      .bookmarkList=${this.bookmarkList}
    336    ></view-opentabs-card>`;
    337  }
    338 
    339  handleEvent({ detail, type }) {
    340    if (this.recentBrowsing && type === "MozInputSearch:search") {
    341      this.onSearchQuery({ detail });
    342      return;
    343    }
    344    let windowIds;
    345    switch (type) {
    346      case "TabRecencyChange":
    347      case "TabChange":
    348        windowIds = detail.windowIds;
    349        this._updateWindowList();
    350        this.bookmarkList.setTrackedUrls(this.#getAllTabUrls());
    351        break;
    352    }
    353    if (this.recentBrowsing) {
    354      return;
    355    }
    356    if (windowIds?.length) {
    357      // there were tab changes to one or more windows
    358      for (let winId of windowIds) {
    359        const cardForWin = this.shadowRoot.querySelector(
    360          `view-opentabs-card[data-inner-id="${winId}"]`
    361        );
    362        if (this.searchQuery) {
    363          cardForWin?.updateSearchResults();
    364        }
    365        cardForWin?.requestUpdate();
    366      }
    367    } else {
    368      let winId = window.windowGlobalChild.innerWindowId;
    369      let cardForWin = this.shadowRoot.querySelector(
    370        `view-opentabs-card[data-inner-id="${winId}"]`
    371      );
    372      if (this.searchQuery) {
    373        cardForWin?.updateSearchResults();
    374      }
    375    }
    376  }
    377 
    378  async _updateWindowList() {
    379    this.windows = this.openTabsTarget.currentWindows;
    380  }
    381 }
    382 customElements.define("view-opentabs", OpenTabsInView);
    383 
    384 /**
    385 * A card which displays a list of open tabs for a window.
    386 *
    387 * @property {boolean} showMore
    388 *   Whether to force all tabs to be shown, regardless of available space.
    389 * @property {MozTabbrowserTab[]} tabs
    390 *   The open tabs to show.
    391 * @property {string} title
    392 *   The window title.
    393 */
    394 class OpenTabsInViewCard extends ViewPageContent {
    395  static properties = {
    396    showMore: { type: Boolean },
    397    tabs: { type: Array },
    398    title: { type: String },
    399    recentBrowsing: { type: Boolean },
    400    searchQuery: { type: String },
    401    searchResults: { type: Array },
    402    showAll: { type: Boolean },
    403    cumulativeSearches: { type: Number },
    404    bookmarkList: { type: Object },
    405  };
    406  static MAX_TABS_FOR_COMPACT_HEIGHT = 7;
    407 
    408  constructor() {
    409    super();
    410    this.showMore = false;
    411    this.tabs = [];
    412    this.title = "";
    413    this.recentBrowsing = false;
    414    this.devices = [];
    415    this.searchQuery = "";
    416    this.searchResults = null;
    417    this.showAll = false;
    418    this.cumulativeSearches = 0;
    419    this.controller = new lazy.OpenTabsController(this, {});
    420  }
    421 
    422  static queries = {
    423    cardEl: "card-container",
    424    tabContextMenu: "view-opentabs-contextmenu",
    425    tabList: "opentabs-tab-list",
    426  };
    427 
    428  openContextMenu(e) {
    429    let { originalEvent } = e.detail;
    430    this.tabContextMenu.toggle({
    431      triggerNode: e.originalTarget,
    432      originalEvent,
    433    });
    434  }
    435 
    436  getMaxTabsLength() {
    437    if (this.recentBrowsing && !this.showAll) {
    438      return MAX_TABS_FOR_RECENT_BROWSING;
    439    } else if (this.classList.contains("height-limited") && !this.showMore) {
    440      return OpenTabsInViewCard.MAX_TABS_FOR_COMPACT_HEIGHT;
    441    }
    442    return -1;
    443  }
    444 
    445  isShowAllLinkVisible() {
    446    return (
    447      this.recentBrowsing &&
    448      this.searchQuery &&
    449      this.searchResults.length > MAX_TABS_FOR_RECENT_BROWSING &&
    450      !this.showAll
    451    );
    452  }
    453 
    454  isShowMoreLinkVisible() {
    455    if (!this.classList.contains("height-limited")) {
    456      return false;
    457    }
    458 
    459    let tabCount = (this.searchQuery ? this.searchResults : this.tabs).length;
    460    return tabCount > OpenTabsInViewCard.MAX_TABS_FOR_COMPACT_HEIGHT;
    461  }
    462 
    463  toggleShowMore(event) {
    464    if (
    465      event.type == "click" ||
    466      (event.type == "keydown" && event.code == "Enter") ||
    467      (event.type == "keydown" && event.code == "Space")
    468    ) {
    469      event.preventDefault();
    470      this.showMore = !this.showMore;
    471    }
    472  }
    473 
    474  enableShowAll(event) {
    475    if (
    476      event.type == "click" ||
    477      (event.type == "keydown" && event.code == "Enter") ||
    478      (event.type == "keydown" && event.code == "Space")
    479    ) {
    480      event.preventDefault();
    481      Glean.firefoxviewNext.searchShowAllShowallbutton.record({
    482        section: "opentabs",
    483      });
    484      this.showAll = true;
    485    }
    486  }
    487 
    488  onTabListRowClick(event) {
    489    // Don't open pinned tab if mute/unmute indicator button selected
    490    if (
    491      Array.from(event.explicitOriginalTarget.classList).includes(
    492        "fxview-tab-row-pinned-media-button"
    493      )
    494    ) {
    495      return;
    496    }
    497    const tab = event.originalTarget.tabElement;
    498    const browserWindow = tab.ownerGlobal;
    499    browserWindow.focus();
    500    browserWindow.gBrowser.selectedTab = tab;
    501 
    502    Glean.firefoxviewNext.openTabTabs.record({
    503      page: this.recentBrowsing ? "recentbrowsing" : "opentabs",
    504      window: this.title || "Window 1 (Current)",
    505    });
    506    if (this.searchQuery) {
    507      Glean.firefoxview.cumulativeSearches[
    508        this.recentBrowsing ? "recentbrowsing" : "opentabs"
    509      ].accumulateSingleSample(this.cumulativeSearches);
    510      this.cumulativeSearches = 0;
    511    }
    512  }
    513 
    514  closeTab(event) {
    515    const tab = event.originalTarget.tabElement;
    516    tab?.ownerGlobal.gBrowser.removeTab(
    517      tab,
    518      lazy.TabMetrics.userTriggeredContext()
    519    );
    520 
    521    Glean.firefoxviewNext.closeOpenTabTabs.record();
    522  }
    523 
    524  viewVisibleCallback() {
    525    this.getRootNode().host.toggleVisibilityInCardContainer(true);
    526  }
    527 
    528  viewHiddenCallback() {
    529    this.getRootNode().host.toggleVisibilityInCardContainer(true);
    530  }
    531 
    532  firstUpdated() {
    533    this.getRootNode().host.toggleVisibilityInCardContainer(true);
    534  }
    535 
    536  render() {
    537    return html`
    538      <link
    539        rel="stylesheet"
    540        href="chrome://browser/content/firefoxview/firefoxview.css"
    541      />
    542      <card-container
    543        ?preserveCollapseState=${this.recentBrowsing}
    544        shortPageName=${this.recentBrowsing ? "opentabs" : null}
    545        ?showViewAll=${this.recentBrowsing}
    546        ?removeBlockEndMargin=${!this.recentBrowsing}
    547      >
    548        ${when(
    549          this.recentBrowsing,
    550          () =>
    551            html`<h3
    552              slot="header"
    553              data-l10n-id="firefoxview-opentabs-header"
    554            ></h3>`,
    555          () => html`<h3 slot="header">${this.title}</h3>`
    556        )}
    557        <div class="fxview-tab-list-container" slot="main">
    558          <opentabs-tab-list
    559            .hasPopup=${"menu"}
    560            ?compactRows=${this.classList.contains("width-limited")}
    561            @fxview-tab-list-primary-action=${this.onTabListRowClick}
    562            @fxview-tab-list-secondary-action=${this.openContextMenu}
    563            @fxview-tab-list-tertiary-action=${this.closeTab}
    564            secondaryActionClass="options-button"
    565            tertiaryActionClass="dismiss-button"
    566            .maxTabsLength=${this.getMaxTabsLength()}
    567            .tabItems=${this.searchResults ||
    568            this.controller.getTabListItems(this.tabs, this.recentBrowsing)}
    569            .searchQuery=${this.searchQuery}
    570            .pinnedTabsGridView=${!this.recentBrowsing}
    571            ><view-opentabs-contextmenu slot="menu"></view-opentabs-contextmenu>
    572          </opentabs-tab-list>
    573        </div>
    574        ${when(
    575          this.recentBrowsing,
    576          () =>
    577            html` <div
    578              @click=${this.enableShowAll}
    579              @keydown=${this.enableShowAll}
    580              data-l10n-id="firefoxview-show-all"
    581              ?hidden=${!this.isShowAllLinkVisible()}
    582              slot="footer"
    583              tabindex="0"
    584              role="link"
    585            ></div>`,
    586          () =>
    587            html` <div
    588              @click=${this.toggleShowMore}
    589              @keydown=${this.toggleShowMore}
    590              data-l10n-id=${this.showMore
    591                ? "firefoxview-show-less"
    592                : "firefoxview-show-more"}
    593              ?hidden=${!this.isShowMoreLinkVisible()}
    594              slot="footer"
    595              tabindex="0"
    596              role="link"
    597            ></div>`
    598        )}
    599      </card-container>
    600    `;
    601  }
    602 
    603  willUpdate(changedProperties) {
    604    if (changedProperties.has("searchQuery")) {
    605      this.showAll = false;
    606      this.cumulativeSearches = this.searchQuery
    607        ? this.cumulativeSearches + 1
    608        : 0;
    609    }
    610    if (changedProperties.has("searchQuery") || changedProperties.has("tabs")) {
    611      this.updateSearchResults();
    612    }
    613  }
    614 
    615  updateSearchResults() {
    616    this.searchResults = this.searchQuery
    617      ? searchTabList(
    618          this.searchQuery,
    619          this.controller.getTabListItems(this.tabs)
    620        )
    621      : null;
    622  }
    623 
    624  updated() {
    625    this.updateBookmarkStars();
    626  }
    627 
    628  async updateBookmarkStars() {
    629    const tabItems = [...this.tabList.tabItems];
    630    for (const row of tabItems) {
    631      const isBookmark = await this.bookmarkList.isBookmark(row.url);
    632      if (isBookmark && !row.indicators.includes("bookmark")) {
    633        row.indicators.push("bookmark");
    634      }
    635      if (!isBookmark && row.indicators.includes("bookmark")) {
    636        row.indicators = row.indicators.filter(i => i !== "bookmark");
    637      }
    638      row.primaryL10nId = this.controller.getPrimaryL10nId(
    639        this.isRecentBrowsing,
    640        row.indicators
    641      );
    642    }
    643    this.tabList.tabItems = tabItems;
    644  }
    645 }
    646 customElements.define("view-opentabs-card", OpenTabsInViewCard);
    647 
    648 /**
    649 * A context menu of actions available for open tab list items.
    650 */
    651 class OpenTabsContextMenu extends MozLitElement {
    652  static properties = {
    653    devices: { type: Array },
    654    triggerNode: { hasChanged: () => true, type: Object },
    655  };
    656 
    657  static queries = {
    658    panelList: "panel-list",
    659  };
    660 
    661  constructor() {
    662    super();
    663    this.triggerNode = null;
    664    this.boundObserve = (...args) => this.observe(...args);
    665    this.devices = [];
    666  }
    667 
    668  get logger() {
    669    return getLogger("OpenTabsContextMenu");
    670  }
    671 
    672  get ownerViewPage() {
    673    return this.ownerDocument.querySelector("view-opentabs");
    674  }
    675 
    676  connectedCallback() {
    677    super.connectedCallback();
    678    this.fetchDevicesPromise = this.fetchDevices();
    679    Services.obs.addObserver(this.boundObserve, TOPIC_DEVICELIST_UPDATED);
    680    Services.obs.addObserver(this.boundObserve, TOPIC_DEVICESTATE_CHANGED);
    681  }
    682 
    683  disconnectedCallback() {
    684    super.disconnectedCallback();
    685    Services.obs.removeObserver(this.boundObserve, TOPIC_DEVICELIST_UPDATED);
    686    Services.obs.removeObserver(this.boundObserve, TOPIC_DEVICESTATE_CHANGED);
    687  }
    688 
    689  observe(_subject, topic, _data) {
    690    if (
    691      topic == TOPIC_DEVICELIST_UPDATED ||
    692      topic == TOPIC_DEVICESTATE_CHANGED
    693    ) {
    694      this.fetchDevicesPromise = this.fetchDevices();
    695    }
    696  }
    697 
    698  async fetchDevices() {
    699    const currentWindow = this.ownerViewPage.getWindow();
    700    if (currentWindow?.gSync) {
    701      try {
    702        await lazy.fxAccounts.device.refreshDeviceList();
    703      } catch (e) {
    704        this.logger.warn("Could not refresh the FxA device list", e);
    705      }
    706      this.devices = currentWindow.gSync.getSendTabTargets();
    707    }
    708  }
    709 
    710  async toggle({ triggerNode, originalEvent }) {
    711    if (this.panelList?.open) {
    712      // the menu will close so avoid all the other work to update its contents
    713      this.panelList.toggle(originalEvent);
    714      return;
    715    }
    716    this.triggerNode = triggerNode;
    717    await this.fetchDevicesPromise;
    718    await this.getUpdateComplete();
    719    this.panelList.toggle(originalEvent);
    720  }
    721 
    722  copyLink(e) {
    723    lazy.BrowserUtils.copyLink(this.triggerNode.url, this.triggerNode.title);
    724    this.ownerViewPage.recordContextMenuTelemetry("copy-link", e);
    725  }
    726 
    727  closeTab(e) {
    728    const tab = this.triggerNode.tabElement;
    729    tab?.ownerGlobal.gBrowser.removeTab(tab);
    730    this.ownerViewPage.recordContextMenuTelemetry("close-tab", e);
    731  }
    732 
    733  pinTab(e) {
    734    const tab = this.triggerNode.tabElement;
    735    tab?.ownerGlobal.gBrowser.pinTab(tab);
    736    this.ownerViewPage.recordContextMenuTelemetry("pin-tab", e);
    737  }
    738 
    739  unpinTab(e) {
    740    const tab = this.triggerNode.tabElement;
    741    tab?.ownerGlobal.gBrowser.unpinTab(tab);
    742    this.ownerViewPage.recordContextMenuTelemetry("unpin-tab", e);
    743  }
    744 
    745  toggleAudio(e) {
    746    const tab = this.triggerNode.tabElement;
    747    tab.toggleMuteAudio();
    748    this.ownerViewPage.recordContextMenuTelemetry(
    749      `${
    750        this.triggerNode.indicators.includes("muted") ? "unmute" : "mute"
    751      }-tab`,
    752      e
    753    );
    754  }
    755 
    756  moveTabsToStart(e) {
    757    const tab = this.triggerNode.tabElement;
    758    tab?.ownerGlobal.gBrowser.moveTabsToStart(tab);
    759    this.ownerViewPage.recordContextMenuTelemetry("move-tab-start", e);
    760  }
    761 
    762  moveTabsToEnd(e) {
    763    const tab = this.triggerNode.tabElement;
    764    tab?.ownerGlobal.gBrowser.moveTabsToEnd(tab);
    765    this.ownerViewPage.recordContextMenuTelemetry("move-tab-end", e);
    766  }
    767 
    768  moveTabsToWindow(e) {
    769    const tab = this.triggerNode.tabElement;
    770    tab?.ownerGlobal.gBrowser.replaceTabsWithWindow(tab);
    771    this.ownerViewPage.recordContextMenuTelemetry("move-tab-window", e);
    772  }
    773 
    774  moveMenuTemplate() {
    775    const tab = this.triggerNode?.tabElement;
    776    if (!tab) {
    777      return null;
    778    }
    779    const browserWindow = tab.ownerGlobal;
    780    const tabs = browserWindow?.gBrowser.visibleTabs || [];
    781    const position = tabs.indexOf(tab);
    782 
    783    return html`
    784      <panel-list slot="submenu" id="move-tab-menu">
    785        ${position > 0
    786          ? html`<panel-item
    787              @click=${this.moveTabsToStart}
    788              data-l10n-id="fxviewtabrow-move-tab-start"
    789              data-l10n-attrs="accesskey"
    790            ></panel-item>`
    791          : null}
    792        ${position < tabs.length - 1
    793          ? html`<panel-item
    794              @click=${this.moveTabsToEnd}
    795              data-l10n-id="fxviewtabrow-move-tab-end"
    796              data-l10n-attrs="accesskey"
    797            ></panel-item>`
    798          : null}
    799        <panel-item
    800          @click=${this.moveTabsToWindow}
    801          data-l10n-id="fxviewtabrow-move-tab-window"
    802          data-l10n-attrs="accesskey"
    803        ></panel-item>
    804      </panel-list>
    805    `;
    806  }
    807 
    808  async sendTabToDevice(e) {
    809    let deviceId = e.target.getAttribute("device-id");
    810    let device = this.devices.find(dev => dev.id == deviceId);
    811    const viewPage = this.ownerViewPage;
    812    viewPage.recordContextMenuTelemetry("send-tab-device", e);
    813 
    814    if (device && this.triggerNode) {
    815      await viewPage
    816        .getWindow()
    817        .gSync.sendTabToDevice(
    818          this.triggerNode.url,
    819          [device],
    820          this.triggerNode.title
    821        );
    822    }
    823  }
    824 
    825  sendTabTemplate() {
    826    return html` <panel-list slot="submenu" id="send-tab-menu">
    827      ${this.devices.map(device => {
    828        return html`
    829          <panel-item @click=${this.sendTabToDevice} device-id=${device.id}
    830            >${device.name}</panel-item
    831          >
    832        `;
    833      })}
    834    </panel-list>`;
    835  }
    836 
    837  render() {
    838    const tab = this.triggerNode?.tabElement;
    839    if (!tab) {
    840      return null;
    841    }
    842 
    843    return html`
    844      <link
    845        rel="stylesheet"
    846        href="chrome://browser/content/firefoxview/firefoxview.css"
    847      />
    848      <panel-list data-tab-type="opentabs">
    849        <panel-item
    850          data-l10n-id="fxviewtabrow-move-tab"
    851          data-l10n-attrs="accesskey"
    852          submenu="move-tab-menu"
    853          >${this.moveMenuTemplate()}</panel-item
    854        >
    855        <panel-item
    856          data-l10n-id=${tab.pinned
    857            ? "fxviewtabrow-unpin-tab"
    858            : "fxviewtabrow-pin-tab"}
    859          data-l10n-attrs="accesskey"
    860          @click=${tab.pinned ? this.unpinTab : this.pinTab}
    861        ></panel-item>
    862        <panel-item
    863          data-l10n-id=${tab.hasAttribute("muted")
    864            ? "fxviewtabrow-unmute-tab"
    865            : "fxviewtabrow-mute-tab"}
    866          data-l10n-attrs="accesskey"
    867          @click=${this.toggleAudio}
    868        ></panel-item>
    869        <hr />
    870        <panel-item
    871          data-l10n-id="fxviewtabrow-copy-link"
    872          data-l10n-attrs="accesskey"
    873          @click=${this.copyLink}
    874        ></panel-item>
    875        ${this.devices.length >= 1
    876          ? html`<panel-item
    877              data-l10n-id="fxviewtabrow-send-to-device"
    878              data-l10n-attrs="accesskey"
    879              submenu="send-tab-menu"
    880              >${this.sendTabTemplate()}</panel-item
    881            >`
    882          : null}
    883      </panel-list>
    884    `;
    885  }
    886 }
    887 customElements.define("view-opentabs-contextmenu", OpenTabsContextMenu);