Backed out changeset 7eda7968bffa (bug 1869833) for causing mochitests failures in...
[gecko.git] / browser / components / firefoxview / tab-pickup-list.mjs
blob4a6fef2d7cd3557bdcae2bddb36e37f756e919c3
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/. */
5 const lazy = {};
6 ChromeUtils.defineESModuleGetters(lazy, {
7   SyncedTabs: "resource://services-sync/SyncedTabs.sys.mjs",
8 });
10 import {
11   formatURIForDisplay,
12   convertTimestamp,
13   getImageUrl,
14   NOW_THRESHOLD_MS,
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 {
24   constructor() {
25     super();
26     this.maxTabsLength = 3;
27     this.currentSyncedTabs = [];
28     this.boundObserve = (...args) => {
29       this.getSyncedTabData(...args);
30     };
32     // The recency timestamp update period is stored in a pref to allow tests to easily change it
33     XPCOMUtils.defineLazyPreferenceGetter(
34       lazy,
35       "timeMsPref",
36       "browser.tabs.firefox-view.updateTimeMs",
37       NOW_THRESHOLD_MS,
38       () => this.updateTime()
39     );
40   }
42   get tabsList() {
43     return this.querySelector("ol");
44   }
46   get fluentStrings() {
47     if (!this._fluentStrings) {
48       this._fluentStrings = new Localization(["browser/firefoxView.ftl"], true);
49     }
50     return this._fluentStrings;
51   }
53   get timeElements() {
54     return this.querySelectorAll("span.synced-tab-li-time");
55   }
57   connectedCallback() {
58     this.placeholderContainer = document.getElementById(
59       "synced-tabs-placeholder"
60     );
61     this.tabPickupContainer = document.getElementById(
62       "tabpickup-tabs-container"
63     );
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 }));
70   }
72   handleEvent(event) {
73     if (
74       event.type == "click" ||
75       (event.type == "keydown" && event.keyCode == KeyEvent.DOM_VK_RETURN)
76     ) {
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(
81         "firefoxview",
82         "tab_pickup",
83         "tabs",
84         null,
85         {
86           position: (++index).toString(),
87           deviceType,
88         }
89       );
90     }
91     if (event.type == "keydown") {
92       switch (event.key) {
93         case "ArrowRight": {
94           event.preventDefault();
95           this.moveFocusToSecondElement();
96           break;
97         }
98         case "ArrowLeft": {
99           event.preventDefault();
100           this.moveFocusToFirstElement();
101           break;
102         }
103         case "ArrowDown": {
104           event.preventDefault();
105           this.moveFocusToNextElement();
106           break;
107         }
108         case "ArrowUp": {
109           event.preventDefault();
110           this.moveFocusToPreviousElement();
111           break;
112         }
113         case "Tab": {
114           this.resetFocus(event);
115         }
116       }
117     }
118   }
120   /**
121    * Handles removing and setting tabindex on elements
122    * while moving focus to the next element
123    *
124    * @param {HTMLElement} currentElement currently focused element
125    * @param {HTMLElement} nextElement element that should receive focus next
126    * @memberof TabPickupList
127    * @private
128    */
129   #manageTabIndexAndFocus(currentElement, nextElement) {
130     currentElement.setAttribute("tabindex", "-1");
131     nextElement.removeAttribute("tabindex");
132     nextElement.focus();
133   }
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);
140   }
142   moveFocusToSecondElement() {
143     let selectableElements = Array.from(this.tabsList.querySelectorAll("a"));
144     let secondElement = selectableElements[1];
145     if (secondElement) {
146       let selectedElement = this.tabsList.querySelector("a:not([tabindex]");
147       this.#manageTabIndexAndFocus(selectedElement, secondElement);
148     }
149   }
151   moveFocusToNextElement() {
152     let selectableElements = Array.from(this.tabsList.querySelectorAll("a"));
153     let selectedElement = this.tabsList.querySelector("a:not([tabindex]");
154     let nextElement =
155       selectableElements.findIndex(elem => elem == selectedElement) + 1;
156     if (nextElement < selectableElements.length) {
157       this.#manageTabIndexAndFocus(
158         selectedElement,
159         selectableElements[nextElement]
160       );
161     }
162   }
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(
170         selectedElement,
171         selectableElements[previousElement]
172       );
173     }
174   }
175   resetFocus(e) {
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");
180     if (e.shiftKey) {
181       e.preventDefault();
182       document
183         .getElementById("tab-pickup-container")
184         .querySelector("summary")
185         .focus();
186     }
187   }
189   cleanup() {
190     Services.obs.removeObserver(this.boundObserve, SYNCED_TABS_CHANGED);
191     clearInterval(this.intervalID);
192   }
194   updateTime() {
195     // when pref is 0, avoid the update altogether (used for tests)
196     if (!lazy.timeMsPref) {
197       return;
198     }
199     for (let timeEl of this.timeElements) {
200       timeEl.textContent = convertTimestamp(
201         parseInt(timeEl.getAttribute("data-timestamp")),
202         this.fluentStrings,
203         lazy.timeMsPref
204       );
205     }
206   }
208   togglePlaceholderVisibility(visible) {
209     this.placeholderContainer.toggleAttribute("hidden", !visible);
210     this.placeholderContainer.classList.toggle("empty-container", visible);
211   }
213   async getSyncedTabData() {
214     let tabs = await lazy.SyncedTabs.getRecentTabs(50);
216     this.updateTabsList(tabs);
217   }
219   tabsEqual(a, b) {
220     return JSON.stringify(a) == JSON.stringify(b);
221   }
223   updateTabsList(syncedTabs) {
224     if (!syncedTabs.length) {
225       while (this.tabsList.firstChild) {
226         this.tabsList.firstChild.remove();
227       }
228       this.togglePlaceholderVisibility(true);
229       this.tabsList.hidden = true;
230       this.currentSyncedTabs = syncedTabs;
231       this.sendTabTelemetry(0);
232       return;
233     }
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++) {
241       tabsToRender.push({
242         type: "placeholder",
243       });
244     }
246     // Return early if new tabs are the same as previous ones
247     if (
248       JSON.stringify(tabsToRender) == JSON.stringify(this.currentSyncedTabs)
249     ) {
250       return;
251     }
253     for (let i = 0; i < tabsToRender.length; i++) {
254       const tabData = tabsToRender[i];
255       let li = this.tabsList.children[i];
256       if (li) {
257         if (this.tabsEqual(tabData, this.currentSyncedTabs[i])) {
258           // Nothing to change
259           continue;
260         }
261         if (tabData.type == "placeholder") {
262           // Replace a tab item with a placeholder
263           this.tabsList.replaceChild(this.generatePlaceholder(), li);
264           continue;
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);
269           li = tabItem;
270         }
271       } else if (tabData.type == "placeholder") {
272         this.tabsList.appendChild(this.generatePlaceholder());
273         continue;
274       } else {
275         li = this.tabsList.appendChild(this.generateListItem(i));
276       }
277       this.updateListItem(li, tabData);
278     }
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);
290       }
291     }
292   }
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);
309     return li;
310   }
312   /*
313      Populate a list item with content from a tab object
314   */
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);
344     url.title = tab.url;
345     document.l10n.setAttributes(url, "firefoxview-tabs-list-tab-button", {
346       targetURI,
347     });
348   }
350   /*
351      Generate an empty list item ready to represent tab data
352   */
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");
360     a.target = "_blank";
361     if (index != 0) {
362       a.setAttribute("tabindex", "-1");
363     }
364     a.addEventListener("keydown", this);
365     li.appendChild(a);
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");
381     a.appendChild(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");
389     a.appendChild(time);
391     return li;
392   }
394   createBadge() {
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"
400     );
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);
407     return badge;
408   }
410   sendTabTelemetry(numTabs) {
411     Services.telemetry.recordEvent("firefoxview", "synced_tabs", "tabs", null, {
412       count: numTabs.toString(),
413     });
414   }
417 customElements.define("tab-pickup-list", TabPickupList);