summaryrefslogtreecommitdiffstats
path: root/browser/components/firefoxview/tab-pickup-list.mjs
diff options
context:
space:
mode:
Diffstat (limited to 'browser/components/firefoxview/tab-pickup-list.mjs')
-rw-r--r--browser/components/firefoxview/tab-pickup-list.mjs417
1 files changed, 417 insertions, 0 deletions
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);