tor-browser

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

opentabs-tab-list.mjs (18856B)


      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  styleMap,
     10  when,
     11 } from "chrome://global/content/vendor/lit.all.mjs";
     12 import {
     13  FxviewTabListBase,
     14  FxviewTabRowBase,
     15 } from "chrome://browser/content/firefoxview/fxview-tab-list.mjs";
     16 // eslint-disable-next-line import/no-unassigned-import
     17 import "chrome://global/content/elements/moz-button.mjs";
     18 
     19 const lazy = {};
     20 let XPCOMUtils;
     21 
     22 XPCOMUtils = ChromeUtils.importESModule(
     23  "resource://gre/modules/XPCOMUtils.sys.mjs"
     24 ).XPCOMUtils;
     25 XPCOMUtils.defineLazyPreferenceGetter(
     26  lazy,
     27  "virtualListEnabledPref",
     28  "browser.firefox-view.virtual-list.enabled"
     29 );
     30 
     31 /**
     32 * A list of clickable tab items
     33 *
     34 * @property {boolean} pinnedTabsGridView - Whether to show pinned tabs in a grid view
     35 */
     36 
     37 export class OpenTabsTabList extends FxviewTabListBase {
     38  constructor() {
     39    super();
     40    this.pinnedTabsGridView = false;
     41    this.pinnedTabs = [];
     42    this.unpinnedTabs = [];
     43  }
     44 
     45  static properties = {
     46    pinnedTabsGridView: { type: Boolean },
     47  };
     48 
     49  static queries = {
     50    ...FxviewTabListBase.queries,
     51    rowEls: {
     52      all: "opentabs-tab-row",
     53    },
     54  };
     55 
     56  willUpdate(changes) {
     57    this.activeIndex = Math.min(
     58      Math.max(this.activeIndex, 0),
     59      this.tabItems.length - 1
     60    );
     61 
     62    if (changes.has("dateTimeFormat") || changes.has("updatesPaused")) {
     63      this.clearIntervalTimer();
     64      if (!this.updatesPaused && this.dateTimeFormat == "relative") {
     65        this.startIntervalTimer();
     66        this.onIntervalUpdate();
     67      }
     68    }
     69 
     70    // Move pinned tabs to the beginning of the list
     71    if (this.pinnedTabsGridView) {
     72      // Can set maxTabsLength to -1 to have no max
     73      this.unpinnedTabs = this.tabItems.filter(
     74        tab => !tab.indicators.includes("pinned")
     75      );
     76      this.pinnedTabs = this.tabItems.filter(tab =>
     77        tab.indicators.includes("pinned")
     78      );
     79      if (this.maxTabsLength > 0) {
     80        this.unpinnedTabs = this.unpinnedTabs.slice(0, this.maxTabsLength);
     81      }
     82      this.tabItems = [...this.pinnedTabs, ...this.unpinnedTabs];
     83    } else if (this.maxTabsLength > 0) {
     84      this.tabItems = this.tabItems.slice(0, this.maxTabsLength);
     85    }
     86  }
     87 
     88  /**
     89   * Focuses the expected element (either the link or button) within fxview-tab-row
     90   * The currently focused/active element ID within a row is stored in this.currentActiveElementId
     91   */
     92  handleFocusElementInRow(e) {
     93    let fxviewTabRow = e.target;
     94    if (e.code == "ArrowUp") {
     95      // Focus either the link or button of the previous row based on this.currentActiveElementId
     96      e.preventDefault();
     97      if (
     98        (this.pinnedTabsGridView &&
     99          this.activeIndex >= this.pinnedTabs.length) ||
    100        !this.pinnedTabsGridView
    101      ) {
    102        this.focusPrevRow();
    103      }
    104    } else if (e.code == "ArrowDown") {
    105      // Focus either the link or button of the next row based on this.currentActiveElementId
    106      e.preventDefault();
    107      if (
    108        this.pinnedTabsGridView &&
    109        this.activeIndex < this.pinnedTabs.length
    110      ) {
    111        this.focusIndex(this.pinnedTabs.length);
    112      } else {
    113        this.focusNextRow();
    114      }
    115    } else if (e.code == "ArrowRight") {
    116      // Focus either the link or the button in the current row and
    117      // set this.currentActiveElementId to that element's ID
    118      e.preventDefault();
    119      if (document.dir == "rtl") {
    120        fxviewTabRow.moveFocusLeft();
    121      } else {
    122        fxviewTabRow.moveFocusRight();
    123      }
    124    } else if (e.code == "ArrowLeft") {
    125      // Focus either the link or the button in the current row and
    126      // set this.currentActiveElementId to that element's ID
    127      e.preventDefault();
    128      if (document.dir == "rtl") {
    129        fxviewTabRow.moveFocusRight();
    130      } else {
    131        fxviewTabRow.moveFocusLeft();
    132      }
    133    }
    134  }
    135 
    136  async focusIndex(index) {
    137    // Focus link or button of item
    138    if (
    139      ((this.pinnedTabsGridView && index > this.pinnedTabs.length) ||
    140        !this.pinnedTabsGridView) &&
    141      lazy.virtualListEnabledPref
    142    ) {
    143      let row = this.rootVirtualListEl.getItem(index - this.pinnedTabs.length);
    144      if (!row) {
    145        return;
    146      }
    147      let subList = this.rootVirtualListEl.getSubListForItem(
    148        index - this.pinnedTabs.length
    149      );
    150      if (!subList) {
    151        return;
    152      }
    153      this.activeIndex = index;
    154 
    155      // In Bug 1866845, these manual updates to the sublists should be removed
    156      // and scrollIntoView() should also be iterated on so that we aren't constantly
    157      // moving the focused item to the center of the viewport
    158      for (const sublist of Array.from(this.rootVirtualListEl.children)) {
    159        await sublist.requestUpdate();
    160        await sublist.updateComplete;
    161      }
    162      row.scrollIntoView({ block: "center" });
    163      row.focus();
    164    } else if (index >= 0 && index < this.rowEls?.length) {
    165      this.rowEls[index].focus();
    166      this.activeIndex = index;
    167    }
    168  }
    169 
    170  #getTabListWrapperClasses() {
    171    let wrapperClasses = ["fxview-tab-list"];
    172    let tabsToCheck = this.pinnedTabsGridView
    173      ? this.unpinnedTabs
    174      : this.tabItems;
    175    if (tabsToCheck.some(tab => tab.containerObj)) {
    176      wrapperClasses.push(`hasContainerTab`);
    177    }
    178    return wrapperClasses;
    179  }
    180 
    181  itemTemplate = (tabItem, i) => {
    182    let time;
    183    if (tabItem.time || tabItem.closedAt) {
    184      let stringTime = (tabItem.time || tabItem.closedAt).toString();
    185      // Different APIs return time in different units, so we use
    186      // the length to decide if it's milliseconds or nanoseconds.
    187      if (stringTime.length === 16) {
    188        time = (tabItem.time || tabItem.closedAt) / 1000;
    189      } else {
    190        time = tabItem.time || tabItem.closedAt;
    191      }
    192    }
    193 
    194    return html`<opentabs-tab-row
    195      ?active=${i == this.activeIndex}
    196      class=${classMap({
    197        pinned:
    198          this.pinnedTabsGridView && tabItem.indicators?.includes("pinned"),
    199      })}
    200      .currentActiveElementId=${this.currentActiveElementId}
    201      .favicon=${tabItem.icon}
    202      .compact=${this.compactRows}
    203      .containerObj=${ifDefined(tabItem.containerObj)}
    204      .indicators=${tabItem.indicators}
    205      .pinnedTabsGridView=${ifDefined(this.pinnedTabsGridView)}
    206      .primaryL10nId=${tabItem.primaryL10nId}
    207      .primaryL10nArgs=${ifDefined(tabItem.primaryL10nArgs)}
    208      .secondaryL10nId=${tabItem.secondaryL10nId}
    209      .secondaryL10nArgs=${ifDefined(tabItem.secondaryL10nArgs)}
    210      .tertiaryL10nId=${ifDefined(tabItem.tertiaryL10nId)}
    211      .tertiaryL10nArgs=${ifDefined(tabItem.tertiaryL10nArgs)}
    212      .secondaryActionClass=${this.secondaryActionClass}
    213      .tertiaryActionClass=${ifDefined(this.tertiaryActionClass)}
    214      .sourceClosedId=${ifDefined(tabItem.sourceClosedId)}
    215      .sourceWindowId=${ifDefined(tabItem.sourceWindowId)}
    216      .closedId=${ifDefined(tabItem.closedId || tabItem.closedId)}
    217      role=${tabItem.pinned && this.pinnedTabsGridView ? "tab" : "listitem"}
    218      .tabElement=${ifDefined(tabItem.tabElement)}
    219      .time=${ifDefined(time)}
    220      .title=${tabItem.title}
    221      .url=${tabItem.url}
    222      .searchQuery=${ifDefined(this.searchQuery)}
    223      .timeMsPref=${ifDefined(this.timeMsPref)}
    224      .hasPopup=${this.hasPopup}
    225      .dateTimeFormat=${this.dateTimeFormat}
    226    ></opentabs-tab-row>`;
    227  };
    228 
    229  render() {
    230    if (this.searchQuery && this.tabItems.length === 0) {
    231      return this.emptySearchResultsTemplate();
    232    }
    233    return html`
    234      ${this.stylesheets()}
    235      <link
    236        rel="stylesheet"
    237        href="chrome://browser/content/firefoxview/opentabs-tab-list.css"
    238      />
    239      ${when(
    240        this.pinnedTabsGridView && this.pinnedTabs.length,
    241        () => html`
    242          <div
    243            id="fxview-tab-list"
    244            class="fxview-tab-list pinned"
    245            data-l10n-id="firefoxview-pinned-tabs"
    246            role="tablist"
    247            @keydown=${this.handleFocusElementInRow}
    248          >
    249            ${this.pinnedTabs.map((tabItem, i) =>
    250              this.customItemTemplate
    251                ? this.customItemTemplate(tabItem, i)
    252                : this.itemTemplate(tabItem, i)
    253            )}
    254          </div>
    255        `
    256      )}
    257      <div
    258        id="fxview-tab-list"
    259        class=${this.#getTabListWrapperClasses().join(" ")}
    260        data-l10n-id="firefoxview-tabs"
    261        role="list"
    262        @keydown=${this.handleFocusElementInRow}
    263      >
    264        ${when(
    265          lazy.virtualListEnabledPref,
    266          () => html`
    267            <virtual-list
    268              .activeIndex=${this.activeIndex}
    269              .pinnedTabsIndexOffset=${this.pinnedTabsGridView
    270                ? this.pinnedTabs.length
    271                : 0}
    272              .items=${this.pinnedTabsGridView
    273                ? this.unpinnedTabs
    274                : this.tabItems}
    275              .template=${this.itemTemplate}
    276            ></virtual-list>
    277          `,
    278          () =>
    279            html`${this.tabItems.map((tabItem, i) =>
    280              this.itemTemplate(tabItem, i)
    281            )}`
    282        )}
    283      </div>
    284      <slot name="menu"></slot>
    285    `;
    286  }
    287 }
    288 customElements.define("opentabs-tab-list", OpenTabsTabList);
    289 
    290 /**
    291 * A tab item that displays favicon, title, url, and time of last access
    292 *
    293 * @property {object} containerObj - Info about an open tab's container if within one
    294 * @property {string} indicators - An array of tab indicators if any are present
    295 * @property {boolean} pinnedTabsGridView - Whether the show pinned tabs in a grid view
    296 */
    297 
    298 export class OpenTabsTabRow extends FxviewTabRowBase {
    299  constructor() {
    300    super();
    301    this.indicators = [];
    302    this.pinnedTabsGridView = false;
    303  }
    304 
    305  static properties = {
    306    ...FxviewTabRowBase.properties,
    307    containerObj: { type: Object },
    308    indicators: { type: Array },
    309    pinnedTabsGridView: { type: Boolean },
    310  };
    311 
    312  static queries = {
    313    ...FxviewTabRowBase.queries,
    314    mediaButtonEl: "#fxview-tab-row-media-button",
    315    pinnedTabButtonEl: "moz-button#fxview-tab-row-main",
    316  };
    317 
    318  connectedCallback() {
    319    super.connectedCallback();
    320    this.addEventListener("keydown", this.handleKeydown);
    321  }
    322 
    323  disconnectedCallback() {
    324    super.disconnectedCallback();
    325    this.removeEventListener("keydown", this.handleKeydown);
    326  }
    327 
    328  handleKeydown(e) {
    329    if (
    330      this.active &&
    331      this.pinnedTabsGridView &&
    332      this.indicators?.includes("pinned") &&
    333      e.key === "m" &&
    334      e.ctrlKey
    335    ) {
    336      this.muteOrUnmuteTab();
    337    }
    338  }
    339 
    340  moveFocusRight() {
    341    let tabList = this.getRootNode().host;
    342    if (this.pinnedTabsGridView && this.indicators?.includes("pinned")) {
    343      tabList.focusNextRow();
    344    } else if (
    345      (this.indicators?.includes("soundplaying") ||
    346        this.indicators?.includes("muted")) &&
    347      this.currentActiveElementId === "fxview-tab-row-main"
    348    ) {
    349      this.focusMediaButton();
    350    } else if (
    351      this.currentActiveElementId === "fxview-tab-row-media-button" ||
    352      this.currentActiveElementId === "fxview-tab-row-main"
    353    ) {
    354      this.focusSecondaryButton();
    355    } else if (
    356      this.tertiaryButtonEl &&
    357      this.currentActiveElementId === "fxview-tab-row-secondary-button"
    358    ) {
    359      this.focusTertiaryButton();
    360    }
    361  }
    362 
    363  moveFocusLeft() {
    364    let tabList = this.getRootNode().host;
    365    if (
    366      this.pinnedTabsGridView &&
    367      (this.indicators?.includes("pinned") ||
    368        (tabList.currentActiveElementId === "fxview-tab-row-main" &&
    369          tabList.activeIndex === tabList.pinnedTabs.length))
    370    ) {
    371      tabList.focusPrevRow();
    372    } else if (
    373      tabList.currentActiveElementId === "fxview-tab-row-tertiary-button"
    374    ) {
    375      this.focusSecondaryButton();
    376    } else if (
    377      (this.indicators?.includes("soundplaying") ||
    378        this.indicators?.includes("muted")) &&
    379      tabList.currentActiveElementId === "fxview-tab-row-secondary-button"
    380    ) {
    381      this.focusMediaButton();
    382    } else {
    383      this.focusLink();
    384    }
    385  }
    386 
    387  focusMediaButton() {
    388    let tabList = this.getRootNode().host;
    389    this.mediaButtonEl.focus();
    390    tabList.currentActiveElementId = this.mediaButtonEl.id;
    391  }
    392 
    393  #secondaryActionHandler(event) {
    394    if (
    395      (this.pinnedTabsGridView &&
    396        this.indicators?.includes("pinned") &&
    397        event.type == "contextmenu") ||
    398      (event.type == "click" && event.detail && !event.altKey) ||
    399      // detail=0 is from keyboard
    400      (event.type == "click" && !event.detail)
    401    ) {
    402      event.preventDefault();
    403      this.dispatchEvent(
    404        new CustomEvent("fxview-tab-list-secondary-action", {
    405          bubbles: true,
    406          composed: true,
    407          detail: { originalEvent: event, item: this },
    408        })
    409      );
    410    }
    411  }
    412 
    413  #faviconTemplate() {
    414    return html`<span
    415      class=${classMap({
    416        "fxview-tab-row-favicon-wrapper": true,
    417        pinned: this.indicators?.includes("pinned"),
    418        pinnedOnNewTab: this.indicators?.includes("pinnedOnNewTab"),
    419        attention: this.indicators?.includes("attention"),
    420        bookmark: this.indicators?.includes("bookmark"),
    421      })}
    422    >
    423      <span
    424        class="fxview-tab-row-favicon icon"
    425        id="fxview-tab-row-favicon"
    426        style=${styleMap({
    427          backgroundImage: `url(${this.getImageUrl(this.favicon, this.url)})`,
    428        })}
    429      ></span>
    430      ${when(
    431        this.pinnedTabsGridView &&
    432          this.indicators?.includes("pinned") &&
    433          (this.indicators?.includes("muted") ||
    434            this.indicators?.includes("soundplaying")),
    435        () => html`
    436          <button
    437            class="fxview-tab-row-pinned-media-button"
    438            id="fxview-tab-row-media-button"
    439            tabindex="-1"
    440            data-l10n-id=${this.indicators?.includes("muted")
    441              ? "fxviewtabrow-unmute-tab-button-no-context"
    442              : "fxviewtabrow-mute-tab-button-no-context"}
    443            muted=${this.indicators?.includes("muted")}
    444            soundplaying=${this.indicators?.includes("soundplaying") &&
    445            !this.indicators?.includes("muted")}
    446            @click=${this.muteOrUnmuteTab}
    447          ></button>
    448        `
    449      )}
    450    </span>`;
    451  }
    452 
    453  #getContainerClasses() {
    454    let containerClasses = ["fxview-tab-row-container-indicator", "icon"];
    455    if (this.containerObj) {
    456      let { icon, color } = this.containerObj;
    457      containerClasses.push(`identity-icon-${icon}`);
    458      containerClasses.push(`identity-color-${color}`);
    459    }
    460    return containerClasses;
    461  }
    462 
    463  muteOrUnmuteTab(e) {
    464    e?.preventDefault();
    465    // If the tab has no sound playing, the mute/unmute button will be removed when toggled.
    466    // We should move the focus to the right in that case. This does not apply to pinned tabs
    467    // on the Open Tabs page.
    468    let shouldMoveFocus =
    469      (!this.pinnedTabsGridView ||
    470        (!this.indicators.includes("pinned") && this.pinnedTabsGridView)) &&
    471      this.mediaButtonEl &&
    472      !this.indicators.includes("soundplaying") &&
    473      this.currentActiveElementId === "fxview-tab-row-media-button";
    474 
    475    // detail=0 is from keyboard
    476    if (e?.type == "click" && !e?.detail && shouldMoveFocus) {
    477      if (document.dir == "rtl") {
    478        this.moveFocusLeft();
    479      } else {
    480        this.moveFocusRight();
    481      }
    482    }
    483    this.tabElement.toggleMuteAudio();
    484  }
    485 
    486  #mediaButtonTemplate() {
    487    return html`${when(
    488      this.indicators?.includes("soundplaying") ||
    489        this.indicators?.includes("muted"),
    490      () =>
    491        html`<moz-button
    492          type="icon ghost"
    493          class="fxview-tab-row-button"
    494          id="fxview-tab-row-media-button"
    495          data-l10n-id=${this.indicators?.includes("muted")
    496            ? "fxviewtabrow-unmute-tab-button-no-context"
    497            : "fxviewtabrow-mute-tab-button-no-context"}
    498          muted=${this.indicators?.includes("muted")}
    499          soundplaying=${this.indicators?.includes("soundplaying") &&
    500          !this.indicators?.includes("muted")}
    501          @click=${this.muteOrUnmuteTab}
    502          tabindex=${this.active &&
    503          this.currentActiveElementId === "fxview-tab-row-media-button"
    504            ? "0"
    505            : "-1"}
    506        ></moz-button>`,
    507      () => html`<span></span>`
    508    )}`;
    509  }
    510 
    511  #containerIndicatorTemplate() {
    512    let tabList = this.getRootNode().host;
    513    let tabsToCheck = tabList.pinnedTabsGridView
    514      ? tabList.unpinnedTabs
    515      : tabList.tabItems;
    516    return html`${when(
    517      tabsToCheck.some(tab => tab.containerObj),
    518      () => html`<span class=${this.#getContainerClasses().join(" ")}></span>`
    519    )}`;
    520  }
    521 
    522  #pinnedTabItemTemplate() {
    523    return html`
    524      <moz-button
    525        type="icon ghost"
    526        id="fxview-tab-row-main"
    527        aria-haspopup=${ifDefined(this.hasPopup)}
    528        data-l10n-id=${ifDefined(this.primaryL10nId)}
    529        data-l10n-args=${ifDefined(this.primaryL10nArgs)}
    530        tabindex=${this.active &&
    531        this.currentActiveElementId === "fxview-tab-row-main"
    532          ? "0"
    533          : "-1"}
    534        role="tab"
    535        @click=${this.primaryActionHandler}
    536        @keydown=${this.primaryActionHandler}
    537        @contextmenu=${this.#secondaryActionHandler}
    538      >
    539        ${this.#faviconTemplate()}
    540      </moz-button>
    541    `;
    542  }
    543 
    544  #unpinnedTabItemTemplate() {
    545    return html`<a
    546        href=${ifDefined(this.url)}
    547        class="fxview-tab-row-main"
    548        id="fxview-tab-row-main"
    549        tabindex=${this.active &&
    550        this.currentActiveElementId === "fxview-tab-row-main"
    551          ? "0"
    552          : "-1"}
    553        data-l10n-id=${ifDefined(this.primaryL10nId)}
    554        data-l10n-args=${ifDefined(this.primaryL10nArgs)}
    555        @click=${this.primaryActionHandler}
    556        @keydown=${this.primaryActionHandler}
    557        title=${!this.primaryL10nId ? this.url : null}
    558      >
    559        ${this.#faviconTemplate()} ${this.titleTemplate()}
    560        ${when(
    561          !this.compact,
    562          () =>
    563            html`${this.#containerIndicatorTemplate()} ${this.urlTemplate()}
    564            ${this.dateTemplate()} ${this.timeTemplate()}`
    565        )}
    566      </a>
    567      ${this.#mediaButtonTemplate()} ${this.secondaryButtonTemplate()}
    568      ${this.tertiaryButtonTemplate()}`;
    569  }
    570 
    571  render() {
    572    return html`
    573      ${this.stylesheets()}
    574      <link
    575        rel="stylesheet"
    576        href="chrome://browser/content/firefoxview/opentabs-tab-row.css"
    577      />
    578      ${when(
    579        this.containerObj,
    580        () => html`
    581          <link
    582            rel="stylesheet"
    583            href="chrome://browser/content/usercontext/usercontext.css"
    584          />
    585        `
    586      )}
    587      ${when(
    588        this.pinnedTabsGridView && this.indicators?.includes("pinned"),
    589        this.#pinnedTabItemTemplate.bind(this),
    590        this.#unpinnedTabItemTemplate.bind(this)
    591      )}
    592    `;
    593  }
    594 }
    595 customElements.define("opentabs-tab-row", OpenTabsTabRow);