forked from mirrors/gecko-dev
417 lines
12 KiB
JavaScript
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);
|