From 6bf0a5cb5034a7e684dcc3500e841785237ce2dd Mon Sep 17 00:00:00 2001 From: Daniel Baumann Date: Sun, 7 Apr 2024 19:32:43 +0200 Subject: Adding upstream version 1:115.7.0. Signed-off-by: Daniel Baumann --- browser/components/firefoxview/tab-pickup-list.mjs | 417 +++++++++++++++++++++ 1 file changed, 417 insertions(+) create mode 100644 browser/components/firefoxview/tab-pickup-list.mjs (limited to 'browser/components/firefoxview/tab-pickup-list.mjs') diff --git a/browser/components/firefoxview/tab-pickup-list.mjs b/browser/components/firefoxview/tab-pickup-list.mjs new file mode 100644 index 0000000000..4a6fef2d7c --- /dev/null +++ b/browser/components/firefoxview/tab-pickup-list.mjs @@ -0,0 +1,417 @@ +/* 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/. */ + +const lazy = {}; +ChromeUtils.defineESModuleGetters(lazy, { + SyncedTabs: "resource://services-sync/SyncedTabs.sys.mjs", +}); + +import { + formatURIForDisplay, + convertTimestamp, + getImageUrl, + NOW_THRESHOLD_MS, +} from "./helpers.mjs"; + +const { XPCOMUtils } = ChromeUtils.importESModule( + "resource://gre/modules/XPCOMUtils.sys.mjs" +); + +const SYNCED_TABS_CHANGED = "services.sync.tabs.changed"; + +class TabPickupList extends HTMLElement { + constructor() { + super(); + this.maxTabsLength = 3; + this.currentSyncedTabs = []; + this.boundObserve = (...args) => { + this.getSyncedTabData(...args); + }; + + // The recency timestamp update period is stored in a pref to allow tests to easily change it + XPCOMUtils.defineLazyPreferenceGetter( + lazy, + "timeMsPref", + "browser.tabs.firefox-view.updateTimeMs", + NOW_THRESHOLD_MS, + () => this.updateTime() + ); + } + + get tabsList() { + return this.querySelector("ol"); + } + + get fluentStrings() { + if (!this._fluentStrings) { + this._fluentStrings = new Localization(["browser/firefoxView.ftl"], true); + } + return this._fluentStrings; + } + + get timeElements() { + return this.querySelectorAll("span.synced-tab-li-time"); + } + + connectedCallback() { + this.placeholderContainer = document.getElementById( + "synced-tabs-placeholder" + ); + this.tabPickupContainer = document.getElementById( + "tabpickup-tabs-container" + ); + + this.addEventListener("click", this); + Services.obs.addObserver(this.boundObserve, SYNCED_TABS_CHANGED); + + // inform ancestor elements our getSyncedTabData method is available to fetch data + this.dispatchEvent(new CustomEvent("list-ready", { bubbles: true })); + } + + handleEvent(event) { + if ( + event.type == "click" || + (event.type == "keydown" && event.keyCode == KeyEvent.DOM_VK_RETURN) + ) { + const item = event.target.closest(".synced-tab-li"); + let index = [...this.tabsList.children].indexOf(item); + let deviceType = item.dataset.deviceType; + Services.telemetry.recordEvent( + "firefoxview", + "tab_pickup", + "tabs", + null, + { + position: (++index).toString(), + deviceType, + } + ); + } + if (event.type == "keydown") { + switch (event.key) { + case "ArrowRight": { + event.preventDefault(); + this.moveFocusToSecondElement(); + break; + } + case "ArrowLeft": { + event.preventDefault(); + this.moveFocusToFirstElement(); + break; + } + case "ArrowDown": { + event.preventDefault(); + this.moveFocusToNextElement(); + break; + } + case "ArrowUp": { + event.preventDefault(); + this.moveFocusToPreviousElement(); + break; + } + case "Tab": { + this.resetFocus(event); + } + } + } + } + + /** + * Handles removing and setting tabindex on elements + * while moving focus to the next element + * + * @param {HTMLElement} currentElement currently focused element + * @param {HTMLElement} nextElement element that should receive focus next + * @memberof TabPickupList + * @private + */ + #manageTabIndexAndFocus(currentElement, nextElement) { + currentElement.setAttribute("tabindex", "-1"); + nextElement.removeAttribute("tabindex"); + nextElement.focus(); + } + + moveFocusToFirstElement() { + let selectableElements = Array.from(this.tabsList.querySelectorAll("a")); + let firstElement = selectableElements[0]; + let selectedElement = this.tabsList.querySelector("a:not([tabindex]"); + this.#manageTabIndexAndFocus(selectedElement, firstElement); + } + + moveFocusToSecondElement() { + let selectableElements = Array.from(this.tabsList.querySelectorAll("a")); + let secondElement = selectableElements[1]; + if (secondElement) { + let selectedElement = this.tabsList.querySelector("a:not([tabindex]"); + this.#manageTabIndexAndFocus(selectedElement, secondElement); + } + } + + moveFocusToNextElement() { + let selectableElements = Array.from(this.tabsList.querySelectorAll("a")); + let selectedElement = this.tabsList.querySelector("a:not([tabindex]"); + let nextElement = + selectableElements.findIndex(elem => elem == selectedElement) + 1; + if (nextElement < selectableElements.length) { + this.#manageTabIndexAndFocus( + selectedElement, + selectableElements[nextElement] + ); + } + } + moveFocusToPreviousElement() { + let selectableElements = Array.from(this.tabsList.querySelectorAll("a")); + let selectedElement = this.tabsList.querySelector("a:not([tabindex]"); + let previousElement = + selectableElements.findIndex(elem => elem == selectedElement) - 1; + if (previousElement >= 0) { + this.#manageTabIndexAndFocus( + selectedElement, + selectableElements[previousElement] + ); + } + } + resetFocus(e) { + let selectableElements = Array.from(this.tabsList.querySelectorAll("a")); + let selectedElement = this.tabsList.querySelector("a:not([tabindex]"); + selectedElement.setAttribute("tabindex", "-1"); + selectableElements[0].removeAttribute("tabindex"); + if (e.shiftKey) { + e.preventDefault(); + document + .getElementById("tab-pickup-container") + .querySelector("summary") + .focus(); + } + } + + cleanup() { + Services.obs.removeObserver(this.boundObserve, SYNCED_TABS_CHANGED); + clearInterval(this.intervalID); + } + + updateTime() { + // when pref is 0, avoid the update altogether (used for tests) + if (!lazy.timeMsPref) { + return; + } + for (let timeEl of this.timeElements) { + timeEl.textContent = convertTimestamp( + parseInt(timeEl.getAttribute("data-timestamp")), + this.fluentStrings, + lazy.timeMsPref + ); + } + } + + togglePlaceholderVisibility(visible) { + this.placeholderContainer.toggleAttribute("hidden", !visible); + this.placeholderContainer.classList.toggle("empty-container", visible); + } + + async getSyncedTabData() { + let tabs = await lazy.SyncedTabs.getRecentTabs(50); + + this.updateTabsList(tabs); + } + + tabsEqual(a, b) { + return JSON.stringify(a) == JSON.stringify(b); + } + + updateTabsList(syncedTabs) { + if (!syncedTabs.length) { + while (this.tabsList.firstChild) { + this.tabsList.firstChild.remove(); + } + this.togglePlaceholderVisibility(true); + this.tabsList.hidden = true; + this.currentSyncedTabs = syncedTabs; + this.sendTabTelemetry(0); + return; + } + + // Slice syncedTabs to maxTabsLength assuming maxTabsLength + // doesn't change between renders + const tabsToRender = syncedTabs.slice(0, this.maxTabsLength); + + // Pad the render list with placeholders + for (let i = tabsToRender.length; i < this.maxTabsLength; i++) { + tabsToRender.push({ + type: "placeholder", + }); + } + + // Return early if new tabs are the same as previous ones + if ( + JSON.stringify(tabsToRender) == JSON.stringify(this.currentSyncedTabs) + ) { + return; + } + + for (let i = 0; i < tabsToRender.length; i++) { + const tabData = tabsToRender[i]; + let li = this.tabsList.children[i]; + if (li) { + if (this.tabsEqual(tabData, this.currentSyncedTabs[i])) { + // Nothing to change + continue; + } + if (tabData.type == "placeholder") { + // Replace a tab item with a placeholder + this.tabsList.replaceChild(this.generatePlaceholder(), li); + continue; + } else if (this.currentSyncedTabs[i]?.type == "placeholder") { + // Replace the placeholder with a tab item + const tabItem = this.generateListItem(i); + this.tabsList.replaceChild(tabItem, li); + li = tabItem; + } + } else if (tabData.type == "placeholder") { + this.tabsList.appendChild(this.generatePlaceholder()); + continue; + } else { + li = this.tabsList.appendChild(this.generateListItem(i)); + } + this.updateListItem(li, tabData); + } + + this.currentSyncedTabs = tabsToRender; + // Record the full tab count + this.sendTabTelemetry(syncedTabs.length); + + if (this.tabsList.hidden) { + this.tabsList.hidden = false; + this.togglePlaceholderVisibility(false); + + if (!this.intervalID) { + this.intervalID = setInterval(() => this.updateTime(), lazy.timeMsPref); + } + } + } + + generatePlaceholder() { + const li = document.createElement("li"); + li.classList.add("synced-tab-li-placeholder"); + li.setAttribute("role", "presentation"); + + const favicon = document.createElement("span"); + favicon.classList.add("li-placeholder-favicon"); + + const title = document.createElement("span"); + title.classList.add("li-placeholder-title"); + + const domain = document.createElement("span"); + domain.classList.add("li-placeholder-domain"); + + li.append(favicon, title, domain); + return li; + } + + /* + Populate a list item with content from a tab object + */ + updateListItem(li, tab) { + const targetURI = tab.url; + const lastUsedMs = tab.lastUsed * 1000; + const deviceText = tab.device; + + li.dataset.deviceType = tab.deviceType; + + li.querySelector("a").href = targetURI; + li.querySelector(".synced-tab-li-title").textContent = tab.title; + + const favicon = li.querySelector(".favicon"); + const imageUrl = getImageUrl(tab.icon, targetURI); + favicon.style.backgroundImage = `url('${imageUrl}')`; + + const time = li.querySelector(".synced-tab-li-time"); + time.textContent = convertTimestamp(lastUsedMs, this.fluentStrings); + time.setAttribute("data-timestamp", lastUsedMs); + + const deviceIcon = document.createElement("div"); + deviceIcon.classList.add("icon", tab.deviceType); + deviceIcon.setAttribute("role", "presentation"); + + const device = li.querySelector(".synced-tab-li-device"); + device.textContent = deviceText; + device.prepend(deviceIcon); + device.title = deviceText; + + const url = li.querySelector(".synced-tab-li-url"); + url.textContent = formatURIForDisplay(tab.url); + url.title = tab.url; + document.l10n.setAttributes(url, "firefoxview-tabs-list-tab-button", { + targetURI, + }); + } + + /* + Generate an empty list item ready to represent tab data + */ + generateListItem(index) { + // Create new list item + const li = document.createElement("li"); + li.classList.add("synced-tab-li"); + + const a = document.createElement("a"); + a.classList.add("synced-tab-a"); + a.target = "_blank"; + if (index != 0) { + a.setAttribute("tabindex", "-1"); + } + a.addEventListener("keydown", this); + li.appendChild(a); + + const favicon = document.createElement("div"); + favicon.classList.add("favicon"); + a.appendChild(favicon); + + // Hide badge with CSS if not the first child + const badge = this.createBadge(); + a.appendChild(badge); + + const title = document.createElement("span"); + title.classList.add("synced-tab-li-title"); + a.appendChild(title); + + const url = document.createElement("span"); + url.classList.add("synced-tab-li-url"); + a.appendChild(url); + + const device = document.createElement("span"); + device.classList.add("synced-tab-li-device"); + a.appendChild(device); + + const time = document.createElement("span"); + time.classList.add("synced-tab-li-time"); + a.appendChild(time); + + return li; + } + + createBadge() { + const badge = document.createElement("div"); + const dot = document.createElement("span"); + const badgeTextEl = document.createElement("span"); + const badgeText = this.fluentStrings.formatValueSync( + "firefoxview-pickup-tabs-badge" + ); + + badgeTextEl.classList.add("badge-text"); + badgeTextEl.textContent = badgeText; + badge.classList.add("last-active-badge"); + dot.classList.add("dot"); + badge.append(dot, badgeTextEl); + return badge; + } + + sendTabTelemetry(numTabs) { + Services.telemetry.recordEvent("firefoxview", "synced_tabs", "tabs", null, { + count: numTabs.toString(), + }); + } +} + +customElements.define("tab-pickup-list", TabPickupList); -- cgit v1.2.3