fune/browser/components/firefoxview/tab-pickup-list.mjs

417 lines
12 KiB
JavaScript

/* 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);