/* This Source Code Form is subject to the terms of the Mozilla Public * License, v. 2.0. If a copy of the MPL was not distributed with this * file, You can obtain one at http://mozilla.org/MPL/2.0/. */ import { classMap, html, ifDefined, repeat, styleMap, when, } from "chrome://global/content/vendor/lit.all.mjs"; import { MozLitElement } from "chrome://global/content/lit-utils.mjs"; import { escapeRegExp } from "./helpers.mjs"; const NOW_THRESHOLD_MS = 91000; const FXVIEW_ROW_HEIGHT_PX = 32; const lazy = {}; let XPCOMUtils; if (!window.IS_STORYBOOK) { XPCOMUtils = ChromeUtils.importESModule( "resource://gre/modules/XPCOMUtils.sys.mjs" ).XPCOMUtils; XPCOMUtils.defineLazyPreferenceGetter( lazy, "virtualListEnabledPref", "browser.firefox-view.virtual-list.enabled" ); ChromeUtils.defineLazyGetter(lazy, "relativeTimeFormat", () => { return new Services.intl.RelativeTimeFormat(undefined, { style: "narrow", }); }); ChromeUtils.defineESModuleGetters(lazy, { BrowserUtils: "resource://gre/modules/BrowserUtils.sys.mjs", }); } /** * A list of clickable tab items * * @property {boolean} compactRows - Whether to hide the URL and date/time for each tab. * @property {string} dateTimeFormat - Expected format for date and/or time * @property {string} hasPopup - The aria-haspopup attribute for the secondary action, if required * @property {number} maxTabsLength - The max number of tabs for the list * @property {boolean} pinnedTabsGridView - Whether to show pinned tabs in a grid view * @property {Array} tabItems - Items to show in the tab list * @property {string} searchQuery - The query string to highlight, if provided. * @property {string} secondaryActionClass - The class used to style the secondary action element * @property {string} tertiaryActionClass - The class used to style the tertiary action element */ export default class FxviewTabList extends MozLitElement { constructor() { super(); window.MozXULElement.insertFTLIfNeeded("toolkit/branding/brandings.ftl"); window.MozXULElement.insertFTLIfNeeded("browser/fxviewTabList.ftl"); this.activeIndex = 0; this.currentActiveElementId = "fxview-tab-row-main"; this.hasPopup = null; this.dateTimeFormat = "relative"; this.maxTabsLength = 25; this.tabItems = []; this.pinnedTabs = []; this.pinnedTabsGridView = false; this.unpinnedTabs = []; this.compactRows = false; this.updatesPaused = true; this.#register(); } static properties = { activeIndex: { type: Number }, compactRows: { type: Boolean }, currentActiveElementId: { type: String }, dateTimeFormat: { type: String }, hasPopup: { type: String }, maxTabsLength: { type: Number }, pinnedTabsGridView: { type: Boolean }, tabItems: { type: Array }, updatesPaused: { type: Boolean }, searchQuery: { type: String }, secondaryActionClass: { type: String }, tertiaryActionClass: { type: String }, }; static queries = { rowEls: { all: "fxview-tab-row" }, rootVirtualListEl: "virtual-list", }; willUpdate(changes) { this.activeIndex = Math.min( Math.max(this.activeIndex, 0), this.tabItems.length - 1 ); if (changes.has("dateTimeFormat") || changes.has("updatesPaused")) { this.clearIntervalTimer(); if ( !this.updatesPaused && this.dateTimeFormat == "relative" && !window.IS_STORYBOOK ) { this.startIntervalTimer(); this.onIntervalUpdate(); } } // Move pinned tabs to the beginning of the list if (this.pinnedTabsGridView) { // Can set maxTabsLength to -1 to have no max this.unpinnedTabs = this.tabItems.filter( tab => !tab.indicators?.includes("pinned") ); this.pinnedTabs = this.tabItems.filter(tab => tab.indicators?.includes("pinned") ); if (this.maxTabsLength > 0) { this.unpinnedTabs = this.unpinnedTabs.slice(0, this.maxTabsLength); } this.tabItems = [...this.pinnedTabs, ...this.unpinnedTabs]; } else if (this.maxTabsLength > 0) { this.tabItems = this.tabItems.slice(0, this.maxTabsLength); } } startIntervalTimer() { this.clearIntervalTimer(); this.intervalID = setInterval( () => this.onIntervalUpdate(), this.timeMsPref ); } clearIntervalTimer() { if (this.intervalID) { clearInterval(this.intervalID); delete this.intervalID; } } #register() { if (!window.IS_STORYBOOK) { XPCOMUtils.defineLazyPreferenceGetter( this, "timeMsPref", "browser.tabs.firefox-view.updateTimeMs", NOW_THRESHOLD_MS, (prefName, oldVal, newVal) => { this.clearIntervalTimer(); if (!this.isConnected) { return; } this.startIntervalTimer(); this.requestUpdate(); } ); } } connectedCallback() { super.connectedCallback(); if ( !this.updatesPaused && this.dateTimeFormat === "relative" && !window.IS_STORYBOOK ) { this.startIntervalTimer(); } } disconnectedCallback() { super.disconnectedCallback(); this.clearIntervalTimer(); } async getUpdateComplete() { await super.getUpdateComplete(); await Promise.all(Array.from(this.rowEls).map(item => item.updateComplete)); } onIntervalUpdate() { this.requestUpdate(); Array.from(this.rowEls).forEach(fxviewTabRow => fxviewTabRow.requestUpdate() ); } /** * Focuses the expected element (either the link or button) within fxview-tab-row * The currently focused/active element ID within a row is stored in this.currentActiveElementId */ handleFocusElementInRow(e) { let fxviewTabRow = e.target; if (e.code == "ArrowUp") { // Focus either the link or button of the previous row based on this.currentActiveElementId e.preventDefault(); if ( (this.pinnedTabsGridView && this.activeIndex >= this.pinnedTabs.length) || !this.pinnedTabsGridView ) { this.focusPrevRow(); } } else if (e.code == "ArrowDown") { // Focus either the link or button of the next row based on this.currentActiveElementId e.preventDefault(); if ( this.pinnedTabsGridView && this.activeIndex < this.pinnedTabs.length ) { this.focusIndex(this.pinnedTabs.length); } else { this.focusNextRow(); } } else if (e.code == "ArrowRight") { // Focus either the link or the button in the current row and // set this.currentActiveElementId to that element's ID e.preventDefault(); if (document.dir == "rtl") { this.moveFocusLeft(fxviewTabRow); } else { this.moveFocusRight(fxviewTabRow); } } else if (e.code == "ArrowLeft") { // Focus either the link or the button in the current row and // set this.currentActiveElementId to that element's ID e.preventDefault(); if (document.dir == "rtl") { this.moveFocusRight(fxviewTabRow); } else { this.moveFocusLeft(fxviewTabRow); } } } moveFocusRight(fxviewTabRow) { if ( this.pinnedTabsGridView && fxviewTabRow.indicators?.includes("pinned") ) { this.focusNextRow(); } else if ( (fxviewTabRow.indicators?.includes("soundplaying") || fxviewTabRow.indicators?.includes("muted")) && this.currentActiveElementId === "fxview-tab-row-main" ) { this.currentActiveElementId = fxviewTabRow.focusMediaButton(); } else if ( this.currentActiveElementId === "fxview-tab-row-media-button" || this.currentActiveElementId === "fxview-tab-row-main" ) { this.currentActiveElementId = fxviewTabRow.focusSecondaryButton(); } else if ( fxviewTabRow.tertiaryButtonEl && this.currentActiveElementId === "fxview-tab-row-secondary-button" ) { this.currentActiveElementId = fxviewTabRow.focusTertiaryButton(); } } moveFocusLeft(fxviewTabRow) { if ( this.pinnedTabsGridView && (fxviewTabRow.indicators?.includes("pinned") || (this.currentActiveElementId === "fxview-tab-row-main" && this.activeIndex === this.pinnedTabs.length)) ) { this.focusPrevRow(); } else if ( this.currentActiveElementId === "fxview-tab-row-tertiary-button" ) { this.currentActiveElementId = fxviewTabRow.focusSecondaryButton(); } else if ( (fxviewTabRow.indicators?.includes("soundplaying") || fxviewTabRow.indicators?.includes("muted")) && this.currentActiveElementId === "fxview-tab-row-secondary-button" ) { this.currentActiveElementId = fxviewTabRow.focusMediaButton(); } else { this.currentActiveElementId = fxviewTabRow.focusLink(); } } focusPrevRow() { this.focusIndex(this.activeIndex - 1); } focusNextRow() { this.focusIndex(this.activeIndex + 1); } async focusIndex(index) { // Focus link or button of item if ( ((this.pinnedTabsGridView && index > this.pinnedTabs.length) || !this.pinnedTabsGridView) && lazy.virtualListEnabledPref ) { let row = this.rootVirtualListEl.getItem(index - this.pinnedTabs.length); if (!row) { return; } let subList = this.rootVirtualListEl.getSubListForItem( index - this.pinnedTabs.length ); if (!subList) { return; } this.activeIndex = index; // In Bug 1866845, these manual updates to the sublists should be removed // and scrollIntoView() should also be iterated on so that we aren't constantly // moving the focused item to the center of the viewport for (const sublist of Array.from(this.rootVirtualListEl.children)) { await sublist.requestUpdate(); await sublist.updateComplete; } row.scrollIntoView({ block: "center" }); row.focus(); } else if (index >= 0 && index < this.rowEls?.length) { this.rowEls[index].focus(); this.activeIndex = index; } } shouldUpdate(changes) { if (changes.has("updatesPaused")) { if (this.updatesPaused) { this.clearIntervalTimer(); } } return !this.updatesPaused; } itemTemplate = (tabItem, i) => { let time; if (tabItem.time || tabItem.closedAt) { let stringTime = (tabItem.time || tabItem.closedAt).toString(); // Different APIs return time in different units, so we use // the length to decide if it's milliseconds or nanoseconds. if (stringTime.length === 16) { time = (tabItem.time || tabItem.closedAt) / 1000; } else { time = tabItem.time || tabItem.closedAt; } } return html` `; }; render() { if (this.searchQuery && this.tabItems.length === 0) { return this.#emptySearchResultsTemplate(); } return html` ${when( this.pinnedTabsGridView && this.pinnedTabs.length, () => html`
${this.pinnedTabs.map((tabItem, i) => this.itemTemplate(tabItem, i) )}
` )}
${when( lazy.virtualListEnabledPref, () => html` ` )} ${when( !lazy.virtualListEnabledPref, () => html` ${this.tabItems.map((tabItem, i) => this.itemTemplate(tabItem, i))} ` )}
`; } #emptySearchResultsTemplate() { return html` `; } } customElements.define("fxview-tab-list", FxviewTabList); /** * A tab item that displays favicon, title, url, and time of last access * * @property {boolean} active - Should current item have focus on keydown * @property {boolean} compact - Whether to hide the URL and date/time for this tab. * @property {object} containerObj - Info about an open tab's container if within one * @property {string} currentActiveElementId - ID of currently focused element within each tab item * @property {string} dateTimeFormat - Expected format for date and/or time * @property {string} hasPopup - The aria-haspopup attribute for the secondary action, if required * @property {string} indicators - An array of tab indicators if any are present * @property {number} closedId - The tab ID for when the tab item was closed. * @property {number} sourceClosedId - The closedId of the closed window its from if applicable * @property {number} sourceWindowId - The sessionstore id of the window its from if applicable * @property {string} favicon - The favicon for the tab item. * @property {boolean} pinnedTabsGridView - Whether the show pinned tabs in a grid view * @property {string} primaryL10nId - The l10n id used for the primary action element * @property {string} primaryL10nArgs - The l10n args used for the primary action element * @property {string} secondaryL10nId - The l10n id used for the secondary action button * @property {string} secondaryL10nArgs - The l10n args used for the secondary action element * @property {string} secondaryActionClass - The class used to style the secondary action element * @property {string} tertiaryL10nId - The l10n id used for the tertiary action button * @property {string} tertiaryL10nArgs - The l10n args used for the tertiary action element * @property {string} tertiaryActionClass - The class used to style the tertiary action element * @property {object} tabElement - The MozTabbrowserTab element for the tab item. * @property {number} time - The timestamp for when the tab was last accessed. * @property {string} title - The title for the tab item. * @property {string} url - The url for the tab item. * @property {number} timeMsPref - The frequency in milliseconds of updates to relative time * @property {string} searchQuery - The query string to highlight, if provided. */ export class FxviewTabRow extends MozLitElement { constructor() { super(); this.active = false; this.currentActiveElementId = "fxview-tab-row-main"; } static properties = { active: { type: Boolean }, compact: { type: Boolean }, containerObj: { type: Object }, currentActiveElementId: { type: String }, dateTimeFormat: { type: String }, favicon: { type: String }, hasPopup: { type: String }, indicators: { type: Array }, pinnedTabsGridView: { type: Boolean }, primaryL10nId: { type: String }, primaryL10nArgs: { type: String }, secondaryL10nId: { type: String }, secondaryL10nArgs: { type: String }, secondaryActionClass: { type: String }, tertiaryL10nId: { type: String }, tertiaryL10nArgs: { type: String }, tertiaryActionClass: { type: String }, closedId: { type: Number }, sourceClosedId: { type: Number }, sourceWindowId: { type: String }, tabElement: { type: Object }, time: { type: Number }, title: { type: String }, timeMsPref: { type: Number }, url: { type: String }, searchQuery: { type: String }, }; static queries = { mainEl: "#fxview-tab-row-main", secondaryButtonEl: "#fxview-tab-row-secondary-button:not([hidden])", tertiaryButtonEl: "#fxview-tab-row-tertiary-button", mediaButtonEl: "#fxview-tab-row-media-button", pinnedTabButtonEl: "button#fxview-tab-row-main", }; get currentFocusable() { let focusItem = this.renderRoot.getElementById(this.currentActiveElementId); if (!focusItem) { focusItem = this.renderRoot.getElementById("fxview-tab-row-main"); } return focusItem; } connectedCallback() { super.connectedCallback(); this.addEventListener("keydown", this.handleKeydown); } disconnectedCallback() { super.disconnectedCallback(); this.removeEventListener("keydown", this.handleKeydown); } handleKeydown(e) { if ( this.active && this.pinnedTabsGridView && this.indicators?.includes("pinned") && e.key === "m" && e.ctrlKey ) { this.muteOrUnmuteTab(); } } focus() { this.currentFocusable.focus(); } focusSecondaryButton() { this.secondaryButtonEl.focus(); return this.secondaryButtonEl.id; } focusTertiaryButton() { this.tertiaryButtonEl.focus(); return this.tertiaryButtonEl.id; } focusMediaButton() { this.mediaButtonEl.focus(); return this.mediaButtonEl.id; } focusLink() { this.mainEl.focus(); return this.mainEl.id; } dateFluentArgs(timestamp, dateTimeFormat) { if (dateTimeFormat === "date" || dateTimeFormat === "dateTime") { return JSON.stringify({ date: timestamp }); } return null; } dateFluentId(timestamp, dateTimeFormat, _nowThresholdMs = NOW_THRESHOLD_MS) { if (!timestamp) { return null; } if (dateTimeFormat === "relative") { const elapsed = Date.now() - timestamp; if (elapsed <= _nowThresholdMs || !lazy.relativeTimeFormat) { // Use a different string for very recent timestamps return "fxviewtabrow-just-now-timestamp"; } return null; } else if (dateTimeFormat === "date" || dateTimeFormat === "dateTime") { return "fxviewtabrow-date"; } return null; } relativeTime(timestamp, dateTimeFormat, _nowThresholdMs = NOW_THRESHOLD_MS) { if (dateTimeFormat === "relative") { const elapsed = Date.now() - timestamp; if (elapsed > _nowThresholdMs && lazy.relativeTimeFormat) { return lazy.relativeTimeFormat.formatBestUnit(new Date(timestamp)); } } return null; } timeFluentId(dateTimeFormat) { if (dateTimeFormat === "time" || dateTimeFormat === "dateTime") { return "fxviewtabrow-time"; } return null; } formatURIForDisplay(uriString) { return !window.IS_STORYBOOK ? lazy.BrowserUtils.formatURIStringForDisplay(uriString) : uriString; } getImageUrl(icon, targetURI) { if (window.IS_STORYBOOK) { return `chrome://global/skin/icons/defaultFavicon.svg`; } if (!icon) { if (targetURI?.startsWith("moz-extension")) { return "chrome://mozapps/skin/extensions/extension.svg"; } return `chrome://global/skin/icons/defaultFavicon.svg`; } // If the icon is not for website (doesn't begin with http), we // display it directly. Otherwise we go through the page-icon // protocol to try to get a cached version. We don't load // favicons directly. if (icon.startsWith("http")) { return `page-icon:${targetURI}`; } return icon; } getContainerClasses() { let containerClasses = ["fxview-tab-row-container-indicator", "icon"]; if (this.containerObj) { let { icon, color } = this.containerObj; containerClasses.push(`identity-icon-${icon}`); containerClasses.push(`identity-color-${color}`); } return containerClasses; } primaryActionHandler(event) { if ( (event.type == "click" && !event.altKey) || (event.type == "keydown" && event.code == "Enter") || (event.type == "keydown" && event.code == "Space") ) { event.preventDefault(); if (!window.IS_STORYBOOK) { this.dispatchEvent( new CustomEvent("fxview-tab-list-primary-action", { bubbles: true, composed: true, detail: { originalEvent: event, item: this }, }) ); } } } secondaryActionHandler(event) { if ( (this.pinnedTabsGridView && this.indicators?.includes("pinned") && event.type == "contextmenu") || (event.type == "click" && event.detail && !event.altKey) || // detail=0 is from keyboard (event.type == "click" && !event.detail) ) { event.preventDefault(); this.dispatchEvent( new CustomEvent("fxview-tab-list-secondary-action", { bubbles: true, composed: true, detail: { originalEvent: event, item: this }, }) ); } } tertiaryActionHandler(event) { if ( (event.type == "click" && event.detail && !event.altKey) || // detail=0 is from keyboard (event.type == "click" && !event.detail) ) { event.preventDefault(); this.dispatchEvent( new CustomEvent("fxview-tab-list-tertiary-action", { bubbles: true, composed: true, detail: { originalEvent: event, item: this }, }) ); } } muteOrUnmuteTab(e) { e?.preventDefault(); // If the tab has no sound playing, the mute/unmute button will be removed when toggled. // We should move the focus to the right in that case. This does not apply to pinned tabs // on the Open Tabs page. let shouldMoveFocus = (!this.pinnedTabsGridView || (!this.indicators.includes("pinned") && this.pinnedTabsGridView)) && this.mediaButtonEl && !this.indicators.includes("soundplaying") && this.currentActiveElementId === "fxview-tab-row-media-button"; // detail=0 is from keyboard if (e?.type == "click" && !e?.detail && shouldMoveFocus) { let tabList = this.getRootNode().host; if (document.dir == "rtl") { tabList.moveFocusLeft(this); } else { tabList.moveFocusRight(this); } } this.tabElement.toggleMuteAudio(); } #faviconTemplate() { return html` ${when( this.pinnedTabsGridView && this.indicators?.includes("pinned") && (this.indicators?.includes("muted") || this.indicators?.includes("soundplaying")), () => html` ` )} `; } #pinnedTabItemTemplate() { return html` `; } #unpinnedTabItemTemplate() { const title = this.title; const relativeString = this.relativeTime( this.time, this.dateTimeFormat, !window.IS_STORYBOOK ? this.timeMsPref : NOW_THRESHOLD_MS ); const dateString = this.dateFluentId( this.time, this.dateTimeFormat, !window.IS_STORYBOOK ? this.timeMsPref : NOW_THRESHOLD_MS ); const dateArgs = this.dateFluentArgs(this.time, this.dateTimeFormat); const timeString = this.timeFluentId(this.dateTimeFormat); const time = this.time; const timeArgs = JSON.stringify({ time }); return html` ${this.#faviconTemplate()} ${when( this.searchQuery, () => this.#highlightSearchMatches(this.searchQuery, title), () => title )} ${when( this.searchQuery, () => this.#highlightSearchMatches( this.searchQuery, this.formatURIForDisplay(this.url) ), () => this.formatURIForDisplay(this.url) )} ${relativeString} ${when( this.indicators?.includes("soundplaying") || this.indicators?.includes("muted"), () => html``, () => html`` )} ${when( this.secondaryL10nId && this.secondaryActionHandler, () => html`` )} ${when( this.tertiaryL10nId && this.tertiaryActionHandler, () => html`` )}`; } render() { return html` ${when( this.containerObj, () => html` ` )} ${when( this.pinnedTabsGridView && this.indicators?.includes("pinned"), this.#pinnedTabItemTemplate.bind(this), this.#unpinnedTabItemTemplate.bind(this) )} `; } /** * Find all matches of query within the given string, and compute the result * to be rendered. * * @param {string} query * @param {string} string */ #highlightSearchMatches(query, string) { const fragments = []; const regex = RegExp(escapeRegExp(query), "dgi"); let prevIndexEnd = 0; let result; while ((result = regex.exec(string)) !== null) { const [indexStart, indexEnd] = result.indices[0]; fragments.push(string.substring(prevIndexEnd, indexStart)); fragments.push( html`${string.substring(indexStart, indexEnd)}` ); prevIndexEnd = regex.lastIndex; } fragments.push(string.substring(prevIndexEnd)); return fragments; } } customElements.define("fxview-tab-row", FxviewTabRow); export class VirtualList extends MozLitElement { static properties = { items: { type: Array }, template: { type: Function }, activeIndex: { type: Number }, itemOffset: { type: Number }, maxRenderCountEstimate: { type: Number, state: true }, itemHeightEstimate: { type: Number, state: true }, isAlwaysVisible: { type: Boolean }, isVisible: { type: Boolean, state: true }, isSubList: { type: Boolean }, pinnedTabsIndexOffset: { type: Number }, }; createRenderRoot() { return this; } constructor() { super(); this.activeIndex = 0; this.itemOffset = 0; this.pinnedTabsIndexOffset = 0; this.items = []; this.subListItems = []; this.itemHeightEstimate = FXVIEW_ROW_HEIGHT_PX; this.maxRenderCountEstimate = Math.max( 40, 2 * Math.ceil(window.innerHeight / this.itemHeightEstimate) ); this.isSubList = false; this.isVisible = false; this.intersectionObserver = new IntersectionObserver( ([entry]) => (this.isVisible = entry.isIntersecting), { root: this.ownerDocument } ); this.resizeObserver = new ResizeObserver(([entry]) => { if (entry.contentRect?.height > 0) { // Update properties on top-level virtual-list this.parentElement.itemHeightEstimate = entry.contentRect.height; this.parentElement.maxRenderCountEstimate = Math.max( 40, 2 * Math.ceil(window.innerHeight / this.itemHeightEstimate) ); } }); } disconnectedCallback() { super.disconnectedCallback(); this.intersectionObserver.disconnect(); this.resizeObserver.disconnect(); } triggerIntersectionObserver() { this.intersectionObserver.unobserve(this); this.intersectionObserver.observe(this); } getSubListForItem(index) { if (this.isSubList) { throw new Error("Cannot get sublist for item"); } return this.children[parseInt(index / this.maxRenderCountEstimate, 10)]; } getItem(index) { if (!this.isSubList) { return this.getSubListForItem(index)?.getItem( index % this.maxRenderCountEstimate ); } return this.children[index]; } willUpdate(changedProperties) { if (changedProperties.has("items") && !this.isSubList) { this.subListItems = []; for (let i = 0; i < this.items.length; i += this.maxRenderCountEstimate) { this.subListItems.push( this.items.slice(i, i + this.maxRenderCountEstimate) ); } this.triggerIntersectionObserver(); } } recalculateAfterWindowResize() { this.maxRenderCountEstimate = Math.max( 40, 2 * Math.ceil(window.innerHeight / this.itemHeightEstimate) ); } firstUpdated() { this.intersectionObserver.observe(this); if (this.isSubList && this.children[0]) { this.resizeObserver.observe(this.children[0]); } } updated(changedProperties) { this.updateListHeight(changedProperties); } updateListHeight(changedProperties) { if ( changedProperties.has("isAlwaysVisible") || changedProperties.has("isVisible") ) { this.style.height = this.isAlwaysVisible || this.isVisible ? "auto" : `${this.items.length * this.itemHeightEstimate}px`; } } get renderItems() { return this.isSubList ? this.items : this.subListItems; } subListTemplate = (data, i) => { return html``; }; itemTemplate = (data, i) => this.template(data, this.itemOffset + i + this.pinnedTabsIndexOffset); render() { if (this.isAlwaysVisible || this.isVisible) { return html` ${repeat( this.renderItems, (data, i) => i, this.isSubList ? this.itemTemplate : this.subListTemplate )} `; } return ""; } } customElements.define("virtual-list", VirtualList);