tor-browser

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

fxview-tab-list.mjs (29419B)


      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  ifDefined,
      9  repeat,
     10  styleMap,
     11  when,
     12 } from "chrome://global/content/vendor/lit.all.mjs";
     13 import { MozLitElement } from "chrome://global/content/lit-utils.mjs";
     14 import { escapeRegExp } from "./search-helpers.mjs";
     15 // eslint-disable-next-line import/no-unassigned-import
     16 import "chrome://global/content/elements/moz-button.mjs";
     17 
     18 const NOW_THRESHOLD_MS = 91000;
     19 const FXVIEW_ROW_HEIGHT_PX = 32;
     20 const lazy = {};
     21 let XPCOMUtils;
     22 
     23 if (!window.IS_STORYBOOK) {
     24  XPCOMUtils = ChromeUtils.importESModule(
     25    "resource://gre/modules/XPCOMUtils.sys.mjs"
     26  ).XPCOMUtils;
     27  XPCOMUtils.defineLazyPreferenceGetter(
     28    lazy,
     29    "virtualListEnabledPref",
     30    "browser.firefox-view.virtual-list.enabled"
     31  );
     32  ChromeUtils.defineLazyGetter(lazy, "relativeTimeFormat", () => {
     33    return new Services.intl.RelativeTimeFormat(undefined, {
     34      style: "narrow",
     35    });
     36  });
     37 
     38  ChromeUtils.defineESModuleGetters(lazy, {
     39    BrowserUtils: "resource://gre/modules/BrowserUtils.sys.mjs",
     40  });
     41 }
     42 
     43 /**
     44 * A list of clickable tab items
     45 *
     46 * @property {boolean} compactRows - Whether to hide the URL and date/time for each tab.
     47 * @property {string} dateTimeFormat - Expected format for date and/or time
     48 * @property {string} hasPopup - The aria-haspopup attribute for the secondary action, if required
     49 * @property {number} maxTabsLength - The max number of tabs for the list
     50 * @property {Array} tabItems - Items to show in the tab list
     51 * @property {string} searchQuery - The query string to highlight, if provided.
     52 * @property {string} secondaryActionClass - The class used to style the secondary action element
     53 * @property {string} tertiaryActionClass - The class used to style the tertiary action element
     54 */
     55 export class FxviewTabListBase extends MozLitElement {
     56  constructor() {
     57    super();
     58    window.MozXULElement.insertFTLIfNeeded("toolkit/branding/brandings.ftl");
     59    window.MozXULElement.insertFTLIfNeeded("browser/fxviewTabList.ftl");
     60    this.activeIndex = 0;
     61    this.currentActiveElementId = "fxview-tab-row-main";
     62    this.hasPopup = null;
     63    this.dateTimeFormat = "relative";
     64    this.maxTabsLength = 25;
     65    this.tabItems = [];
     66    this.compactRows = false;
     67    this.updatesPaused = true;
     68    this.#register();
     69  }
     70 
     71  static properties = {
     72    activeIndex: { type: Number },
     73    compactRows: { type: Boolean },
     74    currentActiveElementId: { type: String },
     75    dateTimeFormat: { type: String },
     76    hasPopup: { type: String },
     77    maxTabsLength: { type: Number },
     78    tabItems: { type: Array },
     79    updatesPaused: { type: Boolean },
     80    searchQuery: { type: String },
     81    secondaryActionClass: { type: String },
     82    tertiaryActionClass: { type: String },
     83  };
     84 
     85  static queries = {
     86    emptyState: "fxview-empty-state",
     87    rowEls: {
     88      all: "fxview-tab-row",
     89    },
     90    rootVirtualListEl: "virtual-list",
     91  };
     92 
     93  willUpdate(changes) {
     94    this.activeIndex = Math.min(
     95      Math.max(this.activeIndex, 0),
     96      this.tabItems.length - 1
     97    );
     98 
     99    if (changes.has("dateTimeFormat") || changes.has("updatesPaused")) {
    100      this.clearIntervalTimer();
    101      if (
    102        !this.updatesPaused &&
    103        this.dateTimeFormat == "relative" &&
    104        !window.IS_STORYBOOK
    105      ) {
    106        this.startIntervalTimer();
    107        this.onIntervalUpdate();
    108      }
    109    }
    110 
    111    if (this.maxTabsLength > 0) {
    112      this.tabItems = this.tabItems.slice(0, this.maxTabsLength);
    113    }
    114  }
    115 
    116  startIntervalTimer() {
    117    this.clearIntervalTimer();
    118    this.intervalID = setInterval(
    119      () => this.onIntervalUpdate(),
    120      this.timeMsPref
    121    );
    122  }
    123 
    124  clearIntervalTimer() {
    125    if (this.intervalID) {
    126      clearInterval(this.intervalID);
    127      delete this.intervalID;
    128    }
    129  }
    130 
    131  #register() {
    132    if (!window.IS_STORYBOOK) {
    133      XPCOMUtils.defineLazyPreferenceGetter(
    134        this,
    135        "timeMsPref",
    136        "browser.tabs.firefox-view.updateTimeMs",
    137        NOW_THRESHOLD_MS,
    138        () => {
    139          this.clearIntervalTimer();
    140          if (!this.isConnected) {
    141            return;
    142          }
    143          this.startIntervalTimer();
    144          this.requestUpdate();
    145        }
    146      );
    147    }
    148  }
    149 
    150  connectedCallback() {
    151    super.connectedCallback();
    152    if (
    153      !this.updatesPaused &&
    154      this.dateTimeFormat === "relative" &&
    155      !window.IS_STORYBOOK
    156    ) {
    157      this.startIntervalTimer();
    158    }
    159  }
    160 
    161  disconnectedCallback() {
    162    super.disconnectedCallback();
    163    this.clearIntervalTimer();
    164  }
    165 
    166  async getUpdateComplete() {
    167    await super.getUpdateComplete();
    168    await Promise.all(Array.from(this.rowEls).map(item => item.updateComplete));
    169  }
    170 
    171  onIntervalUpdate() {
    172    this.requestUpdate();
    173    Array.from(this.rowEls).forEach(fxviewTabRow =>
    174      fxviewTabRow.requestUpdate()
    175    );
    176  }
    177 
    178  /**
    179   * Focuses the expected element (either the link or button) within fxview-tab-row
    180   * The currently focused/active element ID within a row is stored in this.currentActiveElementId
    181   */
    182  handleFocusElementInRow(e) {
    183    let fxviewTabRow = e.target;
    184    if (e.code == "ArrowUp") {
    185      // Focus either the link or button of the previous row based on this.currentActiveElementId
    186      e.preventDefault();
    187      this.focusPrevRow();
    188    } else if (e.code == "ArrowDown") {
    189      // Focus either the link or button of the next row based on this.currentActiveElementId
    190      e.preventDefault();
    191      this.focusNextRow();
    192    } else if (e.code == "ArrowRight") {
    193      // Focus either the link or the button in the current row and
    194      // set this.currentActiveElementId to that element's ID
    195      e.preventDefault();
    196      if (document.dir == "rtl") {
    197        fxviewTabRow.moveFocusLeft();
    198      } else {
    199        fxviewTabRow.moveFocusRight();
    200      }
    201    } else if (e.code == "ArrowLeft") {
    202      // Focus either the link or the button in the current row and
    203      // set this.currentActiveElementId to that element's ID
    204      e.preventDefault();
    205      if (document.dir == "rtl") {
    206        fxviewTabRow.moveFocusRight();
    207      } else {
    208        fxviewTabRow.moveFocusLeft();
    209      }
    210    }
    211  }
    212 
    213  focusPrevRow() {
    214    this.focusIndex(this.activeIndex - 1);
    215  }
    216 
    217  focusNextRow() {
    218    this.focusIndex(this.activeIndex + 1);
    219  }
    220 
    221  async focusIndex(index) {
    222    // Focus link or button of item
    223    if (lazy.virtualListEnabledPref) {
    224      let row = this.rootVirtualListEl.getItem(index);
    225      if (!row) {
    226        return;
    227      }
    228      let subList = this.rootVirtualListEl.getSubListForItem(index);
    229      if (!subList) {
    230        return;
    231      }
    232      this.activeIndex = index;
    233 
    234      // In Bug 1866845, these manual updates to the sublists should be removed
    235      // and scrollIntoView() should also be iterated on so that we aren't constantly
    236      // moving the focused item to the center of the viewport
    237      await this.requestVirtualListUpdate();
    238      row.scrollIntoView({ block: "center" });
    239      row.focus();
    240    } else if (index >= 0 && index < this.rowEls?.length) {
    241      this.rowEls[index].focus();
    242      this.activeIndex = index;
    243    }
    244  }
    245 
    246  async requestVirtualListUpdate() {
    247    for (const sublist of this.rootVirtualListEl.children) {
    248      await sublist.requestUpdate();
    249      await sublist.updateComplete;
    250    }
    251  }
    252 
    253  shouldUpdate(changes) {
    254    if (changes.has("updatesPaused")) {
    255      if (this.updatesPaused) {
    256        this.clearIntervalTimer();
    257      }
    258    }
    259    return !this.updatesPaused;
    260  }
    261 
    262  itemTemplate = (tabItem, i) => {
    263    let time;
    264    if (tabItem.time || tabItem.closedAt) {
    265      let stringTime = (tabItem.time || tabItem.closedAt).toString();
    266      // Different APIs return time in different units, so we use
    267      // the length to decide if it's milliseconds or nanoseconds.
    268      if (stringTime.length === 16) {
    269        time = (tabItem.time || tabItem.closedAt) / 1000;
    270      } else {
    271        time = tabItem.time || tabItem.closedAt;
    272      }
    273    }
    274 
    275    return html`
    276      <fxview-tab-row
    277        ?active=${i == this.activeIndex}
    278        ?compact=${this.compactRows}
    279        .currentActiveElementId=${this.currentActiveElementId}
    280        .favicon=${tabItem.icon}
    281        .primaryL10nId=${tabItem.primaryL10nId}
    282        .primaryL10nArgs=${tabItem.primaryL10nArgs}
    283        .secondaryL10nId=${tabItem.secondaryL10nId}
    284        .secondaryL10nArgs=${tabItem.secondaryL10nArgs}
    285        .tertiaryL10nId=${tabItem.tertiaryL10nId}
    286        .tertiaryL10nArgs=${tabItem.tertiaryL10nArgs}
    287        .secondaryActionClass=${this.secondaryActionClass}
    288        .tertiaryActionClass=${this.tertiaryActionClass}
    289        .sourceClosedId=${tabItem.sourceClosedId}
    290        .sourceWindowId=${tabItem.sourceWindowId}
    291        .closedId=${tabItem.closedId || tabItem.closedId}
    292        role="listitem"
    293        .tabElement=${tabItem.tabElement}
    294        .time=${time}
    295        .title=${tabItem.title}
    296        .url=${tabItem.url}
    297        .searchQuery=${this.searchQuery}
    298        .timeMsPref=${this.timeMsPref}
    299        .hasPopup=${this.hasPopup}
    300        .dateTimeFormat=${this.dateTimeFormat}
    301      ></fxview-tab-row>
    302    `;
    303  };
    304 
    305  stylesheets() {
    306    return html`<link
    307      rel="stylesheet"
    308      href="chrome://browser/content/firefoxview/fxview-tab-list.css"
    309    />`;
    310  }
    311 
    312  render() {
    313    if (this.searchQuery && !this.tabItems.length) {
    314      return this.emptySearchResultsTemplate();
    315    }
    316    return html`
    317      ${this.stylesheets()}
    318      <div
    319        id="fxview-tab-list"
    320        class="fxview-tab-list"
    321        data-l10n-id="firefoxview-tabs"
    322        role="list"
    323        @keydown=${this.handleFocusElementInRow}
    324      >
    325        ${when(
    326          lazy.virtualListEnabledPref,
    327          () => html`
    328            <virtual-list
    329              .activeIndex=${this.activeIndex}
    330              .items=${this.tabItems}
    331              .template=${this.itemTemplate}
    332            ></virtual-list>
    333          `,
    334          () =>
    335            html`${this.tabItems.map((tabItem, i) =>
    336              this.itemTemplate(tabItem, i)
    337            )}`
    338        )}
    339      </div>
    340      <slot name="menu"></slot>
    341    `;
    342  }
    343 
    344  emptySearchResultsTemplate() {
    345    return html` <fxview-empty-state
    346      class="search-results"
    347      headerLabel="firefoxview-search-results-empty"
    348      .headerArgs=${{ query: this.searchQuery }}
    349      isInnerCard
    350    >
    351    </fxview-empty-state>`;
    352  }
    353 }
    354 customElements.define("fxview-tab-list", FxviewTabListBase);
    355 
    356 /**
    357 * A tab item that displays favicon, title, url, and time of last access
    358 *
    359 * @property {boolean} active - Should current item have focus on keydown
    360 * @property {boolean} compact - Whether to hide the URL and date/time for this tab.
    361 * @property {string} currentActiveElementId - ID of currently focused element within each tab item
    362 * @property {string} dateTimeFormat - Expected format for date and/or time
    363 * @property {string} hasPopup - The aria-haspopup attribute for the secondary action, if required
    364 * @property {number} closedId - The tab ID for when the tab item was closed.
    365 * @property {number} sourceClosedId - The closedId of the closed window its from if applicable
    366 * @property {number} sourceWindowId - The sessionstore id of the window its from if applicable
    367 * @property {string} favicon - The favicon for the tab item.
    368 * @property {string} primaryL10nId - The l10n id used for the primary action element
    369 * @property {string} primaryL10nArgs - The l10n args used for the primary action element
    370 * @property {string} secondaryL10nId - The l10n id used for the secondary action button
    371 * @property {string} secondaryL10nArgs - The l10n args used for the secondary action element
    372 * @property {string} secondaryActionClass - The class used to style the secondary action element
    373 * @property {string} tertiaryL10nId - The l10n id used for the tertiary action button
    374 * @property {string} tertiaryL10nArgs - The l10n args used for the tertiary action element
    375 * @property {string} tertiaryActionClass - The class used to style the tertiary action element
    376 * @property {object} tabElement - The MozTabbrowserTab element for the tab item.
    377 * @property {number} time - The timestamp for when the tab was last accessed.
    378 * @property {string} title - The title for the tab item.
    379 * @property {string} url - The url for the tab item.
    380 * @property {number} timeMsPref - The frequency in milliseconds of updates to relative time
    381 * @property {string} searchQuery - The query string to highlight, if provided.
    382 */
    383 export class FxviewTabRowBase extends MozLitElement {
    384  static properties = {
    385    active: { type: Boolean },
    386    compact: { type: Boolean },
    387    currentActiveElementId: { type: String },
    388    dateTimeFormat: { type: String },
    389    favicon: { type: String },
    390    hasPopup: { type: String },
    391    primaryL10nId: { type: String },
    392    primaryL10nArgs: { type: String },
    393    secondaryL10nId: { type: String },
    394    secondaryL10nArgs: { type: String },
    395    secondaryActionClass: { type: String },
    396    tertiaryL10nId: { type: String },
    397    tertiaryL10nArgs: { type: String },
    398    tertiaryActionClass: { type: String },
    399    closedId: { type: Number },
    400    sourceClosedId: { type: Number },
    401    sourceWindowId: { type: String },
    402    tabElement: { type: Object },
    403    time: { type: Number },
    404    title: { type: String },
    405    timeMsPref: { type: Number },
    406    url: { type: String },
    407    uri: { type: String },
    408    searchQuery: { type: String },
    409  };
    410 
    411  constructor() {
    412    super();
    413    this.active = false;
    414    this.currentActiveElementId = "fxview-tab-row-main";
    415  }
    416 
    417  static queries = {
    418    mainEl: "#fxview-tab-row-main",
    419    secondaryButtonEl: "#fxview-tab-row-secondary-button:not([hidden])",
    420    tertiaryButtonEl: "#fxview-tab-row-tertiary-button",
    421  };
    422 
    423  get currentFocusable() {
    424    let focusItem = this.renderRoot.getElementById(this.currentActiveElementId);
    425    if (!focusItem) {
    426      focusItem = this.renderRoot.getElementById("fxview-tab-row-main");
    427    }
    428    return focusItem;
    429  }
    430 
    431  connectedCallback() {
    432    super.connectedCallback();
    433    this.uri = this.url;
    434  }
    435 
    436  focus() {
    437    this.currentFocusable.focus();
    438  }
    439 
    440  focusSecondaryButton() {
    441    let tabList = this.getRootNode().host;
    442    this.secondaryButtonEl.focus();
    443    tabList.currentActiveElementId = this.secondaryButtonEl.id;
    444  }
    445 
    446  focusTertiaryButton() {
    447    let tabList = this.getRootNode().host;
    448    this.tertiaryButtonEl.focus();
    449    tabList.currentActiveElementId = this.tertiaryButtonEl.id;
    450  }
    451 
    452  focusLink() {
    453    let tabList = this.getRootNode().host;
    454    this.mainEl.focus();
    455    tabList.currentActiveElementId = this.mainEl.id;
    456  }
    457 
    458  moveFocusRight() {
    459    if (this.currentActiveElementId === "fxview-tab-row-main") {
    460      this.focusSecondaryButton();
    461    } else if (
    462      this.tertiaryButtonEl &&
    463      this.currentActiveElementId === "fxview-tab-row-secondary-button"
    464    ) {
    465      this.focusTertiaryButton();
    466    }
    467  }
    468 
    469  moveFocusLeft() {
    470    if (this.currentActiveElementId === "fxview-tab-row-tertiary-button") {
    471      this.focusSecondaryButton();
    472    } else {
    473      this.focusLink();
    474    }
    475  }
    476 
    477  dateFluentArgs(timestamp, dateTimeFormat) {
    478    if (dateTimeFormat === "date" || dateTimeFormat === "dateTime") {
    479      return JSON.stringify({ date: timestamp });
    480    }
    481    return null;
    482  }
    483 
    484  dateFluentId(timestamp, dateTimeFormat, _nowThresholdMs = NOW_THRESHOLD_MS) {
    485    if (!timestamp) {
    486      return null;
    487    }
    488    if (dateTimeFormat === "relative") {
    489      const elapsed = Date.now() - timestamp;
    490      if (elapsed <= _nowThresholdMs || !lazy.relativeTimeFormat) {
    491        // Use a different string for very recent timestamps
    492        return "fxviewtabrow-just-now-timestamp";
    493      }
    494      return null;
    495    } else if (dateTimeFormat === "date" || dateTimeFormat === "dateTime") {
    496      return "fxviewtabrow-date";
    497    }
    498    return null;
    499  }
    500 
    501  relativeTime(timestamp, dateTimeFormat, _nowThresholdMs = NOW_THRESHOLD_MS) {
    502    if (dateTimeFormat === "relative") {
    503      const elapsed = Date.now() - timestamp;
    504      if (elapsed > _nowThresholdMs && lazy.relativeTimeFormat) {
    505        return lazy.relativeTimeFormat.formatBestUnit(new Date(timestamp));
    506      }
    507    }
    508    return null;
    509  }
    510 
    511  timeFluentId(dateTimeFormat) {
    512    if (dateTimeFormat === "time" || dateTimeFormat === "dateTime") {
    513      return "fxviewtabrow-time";
    514    }
    515    return null;
    516  }
    517 
    518  formatURIForDisplay(uriString) {
    519    return !window.IS_STORYBOOK
    520      ? lazy.BrowserUtils.formatURIStringForDisplay(uriString, {
    521          showFilenameForLocalURIs: true,
    522        })
    523      : uriString;
    524  }
    525 
    526  getImageUrl(icon, targetURI) {
    527    if (window.IS_STORYBOOK) {
    528      return `chrome://global/skin/icons/defaultFavicon.svg`;
    529    }
    530    if (!icon) {
    531      if (targetURI?.startsWith("moz-extension")) {
    532        return "chrome://mozapps/skin/extensions/extension.svg";
    533      }
    534      return `chrome://global/skin/icons/defaultFavicon.svg`;
    535    }
    536    // If the icon is not for website (doesn't begin with http), we
    537    // display it directly. Otherwise we go through the page-icon
    538    // protocol to try to get a cached version. We don't load
    539    // favicons directly.
    540    if (icon.startsWith("http")) {
    541      return `page-icon:${targetURI}`;
    542    }
    543    return icon;
    544  }
    545 
    546  primaryActionHandler(event) {
    547    if (
    548      (event.type == "click" && !event.altKey) ||
    549      (event.type == "keydown" && event.code == "Enter") ||
    550      (event.type == "keydown" && event.code == "Space")
    551    ) {
    552      event.preventDefault();
    553      if (!window.IS_STORYBOOK) {
    554        this.dispatchEvent(
    555          new CustomEvent("fxview-tab-list-primary-action", {
    556            bubbles: true,
    557            composed: true,
    558            detail: { originalEvent: event, item: this },
    559          })
    560        );
    561      }
    562    }
    563  }
    564 
    565  secondaryActionHandler(event) {
    566    if (
    567      (event.type == "click" && event.detail && !event.altKey) ||
    568      // detail=0 is from keyboard
    569      (event.type == "click" && !event.detail)
    570    ) {
    571      event.preventDefault();
    572      this.dispatchEvent(
    573        new CustomEvent("fxview-tab-list-secondary-action", {
    574          bubbles: true,
    575          composed: true,
    576          detail: { originalEvent: event, item: this },
    577        })
    578      );
    579    }
    580  }
    581 
    582  tertiaryActionHandler(event) {
    583    if (
    584      (event.type == "click" && event.detail && !event.altKey) ||
    585      // detail=0 is from keyboard
    586      (event.type == "click" && !event.detail)
    587    ) {
    588      event.preventDefault();
    589      this.dispatchEvent(
    590        new CustomEvent("fxview-tab-list-tertiary-action", {
    591          bubbles: true,
    592          composed: true,
    593          detail: { originalEvent: event, item: this },
    594        })
    595      );
    596    }
    597  }
    598 
    599  /**
    600   * Find all matches of query within the given string, and compute the result
    601   * to be rendered.
    602   *
    603   * @param {string} query
    604   * @param {string} string
    605   */
    606  highlightSearchMatches(query, string) {
    607    const fragments = [];
    608    const regex = RegExp(escapeRegExp(query), "dgi");
    609    let prevIndexEnd = 0;
    610    let result;
    611    while ((result = regex.exec(string)) !== null) {
    612      const [indexStart, indexEnd] = result.indices[0];
    613      fragments.push(string.substring(prevIndexEnd, indexStart));
    614      fragments.push(
    615        html`<strong>${string.substring(indexStart, indexEnd)}</strong>`
    616      );
    617      prevIndexEnd = regex.lastIndex;
    618    }
    619    fragments.push(string.substring(prevIndexEnd));
    620    return fragments;
    621  }
    622 
    623  stylesheets() {
    624    return html`<link
    625      rel="stylesheet"
    626      href="chrome://browser/content/firefoxview/fxview-tab-row.css"
    627    />`;
    628  }
    629 
    630  faviconTemplate() {
    631    return html`<span
    632      class="fxview-tab-row-favicon icon"
    633      id="fxview-tab-row-favicon"
    634      style=${styleMap({
    635        backgroundImage: `url(${this.getImageUrl(this.favicon, this.url)})`,
    636      })}
    637    ></span>`;
    638  }
    639 
    640  titleTemplate() {
    641    const title = this.title;
    642    return html`<span
    643      class="fxview-tab-row-title text-truncated-ellipsis"
    644      id="fxview-tab-row-title"
    645      dir="auto"
    646    >
    647      ${when(
    648        this.searchQuery,
    649        () => this.highlightSearchMatches(this.searchQuery, title),
    650        () => title
    651      )}
    652    </span>`;
    653  }
    654 
    655  urlTemplate() {
    656    return html`<span
    657      class="fxview-tab-row-url text-truncated-ellipsis"
    658      id="fxview-tab-row-url"
    659    >
    660      ${when(
    661        this.searchQuery,
    662        () =>
    663          this.highlightSearchMatches(
    664            this.searchQuery,
    665            this.formatURIForDisplay(this.url)
    666          ),
    667        () => this.formatURIForDisplay(this.url)
    668      )}
    669    </span>`;
    670  }
    671 
    672  dateTemplate() {
    673    const relativeString = this.relativeTime(
    674      this.time,
    675      this.dateTimeFormat,
    676      !window.IS_STORYBOOK ? this.timeMsPref : NOW_THRESHOLD_MS
    677    );
    678    const dateString = this.dateFluentId(
    679      this.time,
    680      this.dateTimeFormat,
    681      !window.IS_STORYBOOK ? this.timeMsPref : NOW_THRESHOLD_MS
    682    );
    683    const dateArgs = this.dateFluentArgs(this.time, this.dateTimeFormat);
    684    return html`<span class="fxview-tab-row-date" id="fxview-tab-row-date">
    685      <span
    686        ?hidden=${relativeString || !dateString}
    687        data-l10n-id=${ifDefined(dateString)}
    688        data-l10n-args=${ifDefined(dateArgs)}
    689      ></span>
    690      <span ?hidden=${!relativeString}>${relativeString}</span>
    691    </span>`;
    692  }
    693 
    694  timeTemplate() {
    695    const timeString = this.timeFluentId(this.dateTimeFormat);
    696    const time = this.time;
    697    const timeArgs = JSON.stringify({ time });
    698    return html`<span
    699      class="fxview-tab-row-time"
    700      id="fxview-tab-row-time"
    701      ?hidden=${!timeString}
    702      data-timestamp=${ifDefined(this.time)}
    703      data-l10n-id=${ifDefined(timeString)}
    704      data-l10n-args=${ifDefined(timeArgs)}
    705    >
    706    </span>`;
    707  }
    708 
    709  getIconSrc(actionClass) {
    710    let iconSrc;
    711    switch (actionClass) {
    712      case "delete-button":
    713        iconSrc = "chrome://global/skin/icons/delete.svg";
    714        break;
    715      case "dismiss-button":
    716        iconSrc = "chrome://global/skin/icons/close.svg";
    717        break;
    718      case "options-button":
    719        iconSrc = "chrome://global/skin/icons/more.svg";
    720        break;
    721      default:
    722        iconSrc = null;
    723        break;
    724    }
    725    return iconSrc;
    726  }
    727 
    728  secondaryButtonTemplate() {
    729    return html`${when(
    730      this.secondaryL10nId && this.secondaryActionHandler,
    731      () =>
    732        html`<moz-button
    733          type="icon ghost"
    734          class=${classMap({
    735            "fxview-tab-row-button": true,
    736            [this.secondaryActionClass]: this.secondaryActionClass,
    737          })}
    738          id="fxview-tab-row-secondary-button"
    739          data-l10n-id=${this.secondaryL10nId}
    740          data-l10n-args=${ifDefined(this.secondaryL10nArgs)}
    741          aria-haspopup=${ifDefined(this.hasPopup)}
    742          @click=${this.secondaryActionHandler}
    743          tabindex=${this.active &&
    744          this.currentActiveElementId === "fxview-tab-row-secondary-button"
    745            ? "0"
    746            : "-1"}
    747          iconSrc=${this.getIconSrc(this.secondaryActionClass)}
    748        ></moz-button>`
    749    )}`;
    750  }
    751 
    752  tertiaryButtonTemplate() {
    753    return html`${when(
    754      this.tertiaryL10nId && this.tertiaryActionHandler,
    755      () =>
    756        html`<moz-button
    757          type="icon ghost"
    758          class=${classMap({
    759            "fxview-tab-row-button": true,
    760            [this.tertiaryActionClass]: this.tertiaryActionClass,
    761          })}
    762          id="fxview-tab-row-tertiary-button"
    763          data-l10n-id=${this.tertiaryL10nId}
    764          data-l10n-args=${ifDefined(this.tertiaryL10nArgs)}
    765          aria-haspopup=${ifDefined(this.hasPopup)}
    766          @click=${this.tertiaryActionHandler}
    767          tabindex=${this.active &&
    768          this.currentActiveElementId === "fxview-tab-row-tertiary-button"
    769            ? "0"
    770            : "-1"}
    771          iconSrc=${this.getIconSrc(this.tertiaryActionClass)}
    772        ></moz-button>`
    773    )}`;
    774  }
    775 }
    776 
    777 export class FxviewTabRow extends FxviewTabRowBase {
    778  render() {
    779    return html`
    780      ${this.stylesheets()}
    781      <a
    782        href=${ifDefined(this.url)}
    783        class="fxview-tab-row-main"
    784        id="fxview-tab-row-main"
    785        tabindex=${this.active &&
    786        this.currentActiveElementId === "fxview-tab-row-main"
    787          ? "0"
    788          : "-1"}
    789        data-l10n-id=${ifDefined(this.primaryL10nId)}
    790        data-l10n-args=${ifDefined(this.primaryL10nArgs)}
    791        @click=${this.primaryActionHandler}
    792        @keydown=${this.primaryActionHandler}
    793        title=${!this.primaryL10nId ? this.url : null}
    794      >
    795        ${this.faviconTemplate()} ${this.titleTemplate()}
    796        ${when(
    797          !this.compact,
    798          () =>
    799            html`${this.urlTemplate()} ${this.dateTemplate()}
    800            ${this.timeTemplate()}`
    801        )}
    802      </a>
    803      ${this.secondaryButtonTemplate()} ${this.tertiaryButtonTemplate()}
    804    `;
    805  }
    806 }
    807 
    808 customElements.define("fxview-tab-row", FxviewTabRow);
    809 
    810 export class VirtualList extends MozLitElement {
    811  static properties = {
    812    items: { type: Array },
    813    template: { type: Function },
    814    activeIndex: { type: Number },
    815    itemOffset: { type: Number },
    816    maxRenderCountEstimate: { type: Number, state: true },
    817    itemHeightEstimate: { type: Number, state: true },
    818    isAlwaysVisible: { type: Boolean },
    819    isVisible: { type: Boolean, state: true },
    820    isSubList: { type: Boolean },
    821    pinnedTabsIndexOffset: { type: Number },
    822  };
    823 
    824  createRenderRoot() {
    825    return this;
    826  }
    827 
    828  constructor() {
    829    super();
    830    this.activeIndex = 0;
    831    this.itemOffset = 0;
    832    this.pinnedTabsIndexOffset = 0;
    833    this.items = [];
    834    this.subListItems = [];
    835    this.itemHeightEstimate = FXVIEW_ROW_HEIGHT_PX;
    836    this.maxRenderCountEstimate = Math.max(
    837      40,
    838      2 * Math.ceil(window.innerHeight / this.itemHeightEstimate)
    839    );
    840    this.isSubList = false;
    841    this.isVisible = false;
    842    this.intersectionObserver = new IntersectionObserver(
    843      ([entry]) => {
    844        this.isVisible = entry.isIntersecting;
    845      },
    846      { root: this.ownerDocument }
    847    );
    848    this.selfResizeObserver = new ResizeObserver(() => {
    849      // Trigger the intersection observer once the tab rows have rendered
    850      this.triggerIntersectionObserver();
    851    });
    852    this.childResizeObserver = new ResizeObserver(([entry]) => {
    853      if (entry.contentRect?.height > 0) {
    854        // Update properties on top-level virtual-list
    855        this.parentElement.itemHeightEstimate = entry.contentRect.height;
    856        this.parentElement.maxRenderCountEstimate = Math.max(
    857          40,
    858          2 * Math.ceil(window.innerHeight / this.itemHeightEstimate)
    859        );
    860      }
    861    });
    862  }
    863 
    864  disconnectedCallback() {
    865    super.disconnectedCallback();
    866    this.intersectionObserver.disconnect();
    867    this.childResizeObserver.disconnect();
    868    this.selfResizeObserver.disconnect();
    869  }
    870 
    871  triggerIntersectionObserver() {
    872    this.intersectionObserver.unobserve(this);
    873    this.intersectionObserver.observe(this);
    874  }
    875 
    876  getSubListForItem(index) {
    877    if (this.isSubList) {
    878      throw new Error("Cannot get sublist for item");
    879    }
    880    return this.children[parseInt(index / this.maxRenderCountEstimate, 10)];
    881  }
    882 
    883  getItem(index) {
    884    if (!this.isSubList) {
    885      return this.getSubListForItem(index)?.getItem(
    886        index % this.maxRenderCountEstimate
    887      );
    888    }
    889    return this.children[index];
    890  }
    891 
    892  willUpdate(changedProperties) {
    893    if (changedProperties.has("items") && !this.isSubList) {
    894      this.subListItems = [];
    895      for (let i = 0; i < this.items.length; i += this.maxRenderCountEstimate) {
    896        this.subListItems.push(
    897          this.items.slice(i, i + this.maxRenderCountEstimate)
    898        );
    899      }
    900    }
    901  }
    902 
    903  recalculateAfterWindowResize() {
    904    this.maxRenderCountEstimate = Math.max(
    905      40,
    906      2 * Math.ceil(window.innerHeight / this.itemHeightEstimate)
    907    );
    908  }
    909 
    910  firstUpdated() {
    911    this.intersectionObserver.observe(this);
    912    this.selfResizeObserver.observe(this);
    913    if (this.isSubList && this.children[0]) {
    914      this.childResizeObserver.observe(this.children[0]);
    915    }
    916  }
    917 
    918  updated(changedProperties) {
    919    this.updateListHeight(changedProperties);
    920    if (changedProperties.has("items") && !this.isSubList) {
    921      this.triggerIntersectionObserver();
    922    }
    923  }
    924 
    925  updateListHeight(changedProperties) {
    926    if (
    927      changedProperties.has("isAlwaysVisible") ||
    928      changedProperties.has("isVisible")
    929    ) {
    930      this.style.height =
    931        this.isAlwaysVisible || this.isVisible
    932          ? "auto"
    933          : `${this.items.length * this.itemHeightEstimate}px`;
    934    }
    935  }
    936 
    937  get renderItems() {
    938    return this.isSubList ? this.items : this.subListItems;
    939  }
    940 
    941  subListTemplate = (data, i) => {
    942    return html`<virtual-list
    943      .template=${this.template}
    944      .items=${data}
    945      .itemHeightEstimate=${this.itemHeightEstimate}
    946      .itemOffset=${i * this.maxRenderCountEstimate +
    947      this.pinnedTabsIndexOffset}
    948      .isAlwaysVisible=${i ==
    949      parseInt(this.activeIndex / this.maxRenderCountEstimate, 10)}
    950      isSubList
    951    ></virtual-list>`;
    952  };
    953 
    954  itemTemplate = (data, i) =>
    955    this.template(data, this.itemOffset + i + this.pinnedTabsIndexOffset);
    956 
    957  render() {
    958    if (this.isAlwaysVisible || this.isVisible) {
    959      return html`
    960        ${repeat(
    961          this.renderItems,
    962          (data, i) => i,
    963          this.isSubList ? this.itemTemplate : this.subListTemplate
    964        )}
    965      `;
    966    }
    967    return "";
    968  }
    969 }
    970 customElements.define("virtual-list", VirtualList);