1 /* This Source Code Form is subject to the terms of the Mozilla Public
2 * License, v. 2.0. If a copy of the MPL was not distributed with this
3 * file, You can obtain one at http://mozilla.org/MPL/2.0/. */
6 ChromeUtils.defineESModuleGetters(lazy, {
7 SyncedTabs: "resource://services-sync/SyncedTabs.sys.mjs",
15 } from "./helpers.mjs";
17 const { XPCOMUtils } = ChromeUtils.importESModule(
18 "resource://gre/modules/XPCOMUtils.sys.mjs"
21 const SYNCED_TABS_CHANGED = "services.sync.tabs.changed";
23 class TabPickupList extends HTMLElement {
26 this.maxTabsLength = 3;
27 this.currentSyncedTabs = [];
28 this.boundObserve = (...args) => {
29 this.getSyncedTabData(...args);
32 // The recency timestamp update period is stored in a pref to allow tests to easily change it
33 XPCOMUtils.defineLazyPreferenceGetter(
36 "browser.tabs.firefox-view.updateTimeMs",
38 () => this.updateTime()
43 return this.querySelector("ol");
47 if (!this._fluentStrings) {
48 this._fluentStrings = new Localization(["browser/firefoxView.ftl"], true);
50 return this._fluentStrings;
54 return this.querySelectorAll("span.synced-tab-li-time");
58 this.placeholderContainer = document.getElementById(
59 "synced-tabs-placeholder"
61 this.tabPickupContainer = document.getElementById(
62 "tabpickup-tabs-container"
65 this.addEventListener("click", this);
66 Services.obs.addObserver(this.boundObserve, SYNCED_TABS_CHANGED);
68 // inform ancestor elements our getSyncedTabData method is available to fetch data
69 this.dispatchEvent(new CustomEvent("list-ready", { bubbles: true }));
74 event.type == "click" ||
75 (event.type == "keydown" && event.keyCode == KeyEvent.DOM_VK_RETURN)
77 const item = event.target.closest(".synced-tab-li");
78 let index = [...this.tabsList.children].indexOf(item);
79 let deviceType = item.dataset.deviceType;
80 Services.telemetry.recordEvent(
86 position: (++index).toString(),
91 if (event.type == "keydown") {
94 event.preventDefault();
95 this.moveFocusToSecondElement();
99 event.preventDefault();
100 this.moveFocusToFirstElement();
104 event.preventDefault();
105 this.moveFocusToNextElement();
109 event.preventDefault();
110 this.moveFocusToPreviousElement();
114 this.resetFocus(event);
121 * Handles removing and setting tabindex on elements
122 * while moving focus to the next element
124 * @param {HTMLElement} currentElement currently focused element
125 * @param {HTMLElement} nextElement element that should receive focus next
126 * @memberof TabPickupList
129 #manageTabIndexAndFocus(currentElement, nextElement) {
130 currentElement.setAttribute("tabindex", "-1");
131 nextElement.removeAttribute("tabindex");
135 moveFocusToFirstElement() {
136 let selectableElements = Array.from(this.tabsList.querySelectorAll("a"));
137 let firstElement = selectableElements[0];
138 let selectedElement = this.tabsList.querySelector("a:not([tabindex]");
139 this.#manageTabIndexAndFocus(selectedElement, firstElement);
142 moveFocusToSecondElement() {
143 let selectableElements = Array.from(this.tabsList.querySelectorAll("a"));
144 let secondElement = selectableElements[1];
146 let selectedElement = this.tabsList.querySelector("a:not([tabindex]");
147 this.#manageTabIndexAndFocus(selectedElement, secondElement);
151 moveFocusToNextElement() {
152 let selectableElements = Array.from(this.tabsList.querySelectorAll("a"));
153 let selectedElement = this.tabsList.querySelector("a:not([tabindex]");
155 selectableElements.findIndex(elem => elem == selectedElement) + 1;
156 if (nextElement < selectableElements.length) {
157 this.#manageTabIndexAndFocus(
159 selectableElements[nextElement]
163 moveFocusToPreviousElement() {
164 let selectableElements = Array.from(this.tabsList.querySelectorAll("a"));
165 let selectedElement = this.tabsList.querySelector("a:not([tabindex]");
166 let previousElement =
167 selectableElements.findIndex(elem => elem == selectedElement) - 1;
168 if (previousElement >= 0) {
169 this.#manageTabIndexAndFocus(
171 selectableElements[previousElement]
176 let selectableElements = Array.from(this.tabsList.querySelectorAll("a"));
177 let selectedElement = this.tabsList.querySelector("a:not([tabindex]");
178 selectedElement.setAttribute("tabindex", "-1");
179 selectableElements[0].removeAttribute("tabindex");
183 .getElementById("tab-pickup-container")
184 .querySelector("summary")
190 Services.obs.removeObserver(this.boundObserve, SYNCED_TABS_CHANGED);
191 clearInterval(this.intervalID);
195 // when pref is 0, avoid the update altogether (used for tests)
196 if (!lazy.timeMsPref) {
199 for (let timeEl of this.timeElements) {
200 timeEl.textContent = convertTimestamp(
201 parseInt(timeEl.getAttribute("data-timestamp")),
208 togglePlaceholderVisibility(visible) {
209 this.placeholderContainer.toggleAttribute("hidden", !visible);
210 this.placeholderContainer.classList.toggle("empty-container", visible);
213 async getSyncedTabData() {
214 let tabs = await lazy.SyncedTabs.getRecentTabs(50);
216 this.updateTabsList(tabs);
220 return JSON.stringify(a) == JSON.stringify(b);
223 updateTabsList(syncedTabs) {
224 if (!syncedTabs.length) {
225 while (this.tabsList.firstChild) {
226 this.tabsList.firstChild.remove();
228 this.togglePlaceholderVisibility(true);
229 this.tabsList.hidden = true;
230 this.currentSyncedTabs = syncedTabs;
231 this.sendTabTelemetry(0);
235 // Slice syncedTabs to maxTabsLength assuming maxTabsLength
236 // doesn't change between renders
237 const tabsToRender = syncedTabs.slice(0, this.maxTabsLength);
239 // Pad the render list with placeholders
240 for (let i = tabsToRender.length; i < this.maxTabsLength; i++) {
246 // Return early if new tabs are the same as previous ones
248 JSON.stringify(tabsToRender) == JSON.stringify(this.currentSyncedTabs)
253 for (let i = 0; i < tabsToRender.length; i++) {
254 const tabData = tabsToRender[i];
255 let li = this.tabsList.children[i];
257 if (this.tabsEqual(tabData, this.currentSyncedTabs[i])) {
261 if (tabData.type == "placeholder") {
262 // Replace a tab item with a placeholder
263 this.tabsList.replaceChild(this.generatePlaceholder(), li);
265 } else if (this.currentSyncedTabs[i]?.type == "placeholder") {
266 // Replace the placeholder with a tab item
267 const tabItem = this.generateListItem(i);
268 this.tabsList.replaceChild(tabItem, li);
271 } else if (tabData.type == "placeholder") {
272 this.tabsList.appendChild(this.generatePlaceholder());
275 li = this.tabsList.appendChild(this.generateListItem(i));
277 this.updateListItem(li, tabData);
280 this.currentSyncedTabs = tabsToRender;
281 // Record the full tab count
282 this.sendTabTelemetry(syncedTabs.length);
284 if (this.tabsList.hidden) {
285 this.tabsList.hidden = false;
286 this.togglePlaceholderVisibility(false);
288 if (!this.intervalID) {
289 this.intervalID = setInterval(() => this.updateTime(), lazy.timeMsPref);
294 generatePlaceholder() {
295 const li = document.createElement("li");
296 li.classList.add("synced-tab-li-placeholder");
297 li.setAttribute("role", "presentation");
299 const favicon = document.createElement("span");
300 favicon.classList.add("li-placeholder-favicon");
302 const title = document.createElement("span");
303 title.classList.add("li-placeholder-title");
305 const domain = document.createElement("span");
306 domain.classList.add("li-placeholder-domain");
308 li.append(favicon, title, domain);
313 Populate a list item with content from a tab object
315 updateListItem(li, tab) {
316 const targetURI = tab.url;
317 const lastUsedMs = tab.lastUsed * 1000;
318 const deviceText = tab.device;
320 li.dataset.deviceType = tab.deviceType;
322 li.querySelector("a").href = targetURI;
323 li.querySelector(".synced-tab-li-title").textContent = tab.title;
325 const favicon = li.querySelector(".favicon");
326 const imageUrl = getImageUrl(tab.icon, targetURI);
327 favicon.style.backgroundImage = `url('${imageUrl}')`;
329 const time = li.querySelector(".synced-tab-li-time");
330 time.textContent = convertTimestamp(lastUsedMs, this.fluentStrings);
331 time.setAttribute("data-timestamp", lastUsedMs);
333 const deviceIcon = document.createElement("div");
334 deviceIcon.classList.add("icon", tab.deviceType);
335 deviceIcon.setAttribute("role", "presentation");
337 const device = li.querySelector(".synced-tab-li-device");
338 device.textContent = deviceText;
339 device.prepend(deviceIcon);
340 device.title = deviceText;
342 const url = li.querySelector(".synced-tab-li-url");
343 url.textContent = formatURIForDisplay(tab.url);
345 document.l10n.setAttributes(url, "firefoxview-tabs-list-tab-button", {
351 Generate an empty list item ready to represent tab data
353 generateListItem(index) {
354 // Create new list item
355 const li = document.createElement("li");
356 li.classList.add("synced-tab-li");
358 const a = document.createElement("a");
359 a.classList.add("synced-tab-a");
362 a.setAttribute("tabindex", "-1");
364 a.addEventListener("keydown", this);
367 const favicon = document.createElement("div");
368 favicon.classList.add("favicon");
369 a.appendChild(favicon);
371 // Hide badge with CSS if not the first child
372 const badge = this.createBadge();
373 a.appendChild(badge);
375 const title = document.createElement("span");
376 title.classList.add("synced-tab-li-title");
377 a.appendChild(title);
379 const url = document.createElement("span");
380 url.classList.add("synced-tab-li-url");
383 const device = document.createElement("span");
384 device.classList.add("synced-tab-li-device");
385 a.appendChild(device);
387 const time = document.createElement("span");
388 time.classList.add("synced-tab-li-time");
395 const badge = document.createElement("div");
396 const dot = document.createElement("span");
397 const badgeTextEl = document.createElement("span");
398 const badgeText = this.fluentStrings.formatValueSync(
399 "firefoxview-pickup-tabs-badge"
402 badgeTextEl.classList.add("badge-text");
403 badgeTextEl.textContent = badgeText;
404 badge.classList.add("last-active-badge");
405 dot.classList.add("dot");
406 badge.append(dot, badgeTextEl);
410 sendTabTelemetry(numTabs) {
411 Services.telemetry.recordEvent("firefoxview", "synced_tabs", "tabs", null, {
412 count: numTabs.toString(),
417 customElements.define("tab-pickup-list", TabPickupList);