Bug 1944627 - update sidebar button checked state for non-revamped sidebar cases...
[gecko.git] / browser / components / firefoxview / opentabs.mjs
blob05e331a19cf5e9b6571a99f574faa2118b11c97a
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 import {
6   classMap,
7   html,
8   map,
9   when,
10 } from "chrome://global/content/vendor/lit.all.mjs";
11 import { MozLitElement } from "chrome://global/content/lit-utils.mjs";
12 import {
13   getLogger,
14   placeLinkOnClipboard,
15   MAX_TABS_FOR_RECENT_BROWSING,
16 } from "./helpers.mjs";
17 import { searchTabList } from "./search-helpers.mjs";
18 import { ViewPage, ViewPageContent } from "./viewpage.mjs";
19 // eslint-disable-next-line import/no-unassigned-import
20 import "chrome://browser/content/firefoxview/opentabs-tab-list.mjs";
22 const lazy = {};
24 ChromeUtils.defineESModuleGetters(lazy, {
25   BookmarkList: "resource://gre/modules/BookmarkList.sys.mjs",
26   ContextualIdentityService:
27     "resource://gre/modules/ContextualIdentityService.sys.mjs",
28   NewTabUtils: "resource://gre/modules/NewTabUtils.sys.mjs",
29   NonPrivateTabs: "resource:///modules/OpenTabs.sys.mjs",
30   getTabsTargetForWindow: "resource:///modules/OpenTabs.sys.mjs",
31   PrivateBrowsingUtils: "resource://gre/modules/PrivateBrowsingUtils.sys.mjs",
32 });
34 ChromeUtils.defineLazyGetter(lazy, "fxAccounts", () => {
35   return ChromeUtils.importESModule(
36     "resource://gre/modules/FxAccounts.sys.mjs"
37   ).getFxAccountsSingleton();
38 });
40 const TOPIC_DEVICESTATE_CHANGED = "firefox-view.devicestate.changed";
41 const TOPIC_DEVICELIST_UPDATED = "fxaccounts:devicelist_updated";
43 /**
44  * A collection of open tabs grouped by window.
45  *
46  * @property {Array<Window>} windows
47  *   A list of windows with the same privateness
48  * @property {string} sortOption
49  *   The sorting order of open tabs:
50  *   - "recency": Sorted by recent activity. (For recent browsing, this is the only option.)
51  *   - "tabStripOrder": Match the order in which they appear on the tab strip.
52  */
53 class OpenTabsInView extends ViewPage {
54   static properties = {
55     ...ViewPage.properties,
56     windows: { type: Array },
57     searchQuery: { type: String },
58     sortOption: { type: String },
59   };
60   static queries = {
61     viewCards: { all: "view-opentabs-card" },
62     optionsContainer: ".open-tabs-options",
63     searchTextbox: "fxview-search-textbox",
64   };
66   initialWindowsReady = false;
67   currentWindow = null;
68   openTabsTarget = null;
70   constructor() {
71     super();
72     this._started = false;
73     this.windows = [];
74     this.currentWindow = this.getWindow();
75     if (lazy.PrivateBrowsingUtils.isWindowPrivate(this.currentWindow)) {
76       this.openTabsTarget = lazy.getTabsTargetForWindow(this.currentWindow);
77     } else {
78       this.openTabsTarget = lazy.NonPrivateTabs;
79     }
80     this.searchQuery = "";
81     this.sortOption = this.recentBrowsing
82       ? "recency"
83       : Services.prefs.getStringPref(
84           "browser.tabs.firefox-view.ui-state.opentabs.sort-option",
85           "recency"
86         );
87   }
89   start() {
90     if (this._started) {
91       return;
92     }
93     this._started = true;
94     this.#setupTabChangeListener();
96     // To resolve the race between this component wanting to render all the windows'
97     // tabs, while those windows are still potentially opening, flip this property
98     // once the promise resolves and we'll bail out of rendering until then.
99     this.openTabsTarget.readyWindowsPromise.finally(() => {
100       this.initialWindowsReady = true;
101       this._updateWindowList();
102     });
104     for (let card of this.viewCards) {
105       card.paused = false;
106       card.viewVisibleCallback?.();
107     }
109     if (this.recentBrowsing) {
110       this.recentBrowsingElement.addEventListener(
111         "fxview-search-textbox-query",
112         this
113       );
114     }
116     this.bookmarkList = new lazy.BookmarkList(this.#getAllTabUrls(), () =>
117       this.viewCards.forEach(card => card.requestUpdate())
118     );
119   }
121   shouldUpdate(changedProperties) {
122     if (!this.initialWindowsReady) {
123       return false;
124     }
125     return super.shouldUpdate(changedProperties);
126   }
128   disconnectedCallback() {
129     super.disconnectedCallback();
130     this.stop();
131   }
133   stop() {
134     if (!this._started) {
135       return;
136     }
137     this._started = false;
138     this.paused = true;
140     this.openTabsTarget.removeEventListener("TabChange", this);
141     this.openTabsTarget.removeEventListener("TabRecencyChange", this);
143     for (let card of this.viewCards) {
144       card.paused = true;
145       card.viewHiddenCallback?.();
146     }
148     if (this.recentBrowsing) {
149       this.recentBrowsingElement.removeEventListener(
150         "fxview-search-textbox-query",
151         this
152       );
153     }
155     this.bookmarkList.removeListeners();
156   }
158   viewVisibleCallback() {
159     this.start();
160   }
162   viewHiddenCallback() {
163     this.stop();
164   }
166   #setupTabChangeListener() {
167     if (this.sortOption === "recency") {
168       this.openTabsTarget.addEventListener("TabRecencyChange", this);
169       this.openTabsTarget.removeEventListener("TabChange", this);
170     } else {
171       this.openTabsTarget.removeEventListener("TabRecencyChange", this);
172       this.openTabsTarget.addEventListener("TabChange", this);
173     }
174   }
176   #getAllTabUrls() {
177     return this.openTabsTarget
178       .getAllTabs()
179       .map(({ linkedBrowser }) => linkedBrowser?.currentURI?.spec)
180       .filter(Boolean);
181   }
183   render() {
184     if (this.recentBrowsing) {
185       return this.getRecentBrowsingTemplate();
186     }
187     let currentWindowIndex, currentWindowTabs;
188     let index = 1;
189     const otherWindows = [];
190     this.windows.forEach(win => {
191       const tabs = this.openTabsTarget.getTabsForWindow(
192         win,
193         this.sortOption === "recency"
194       );
195       if (win === this.currentWindow) {
196         currentWindowIndex = index++;
197         currentWindowTabs = tabs;
198       } else {
199         otherWindows.push([index++, tabs, win]);
200       }
201     });
203     const cardClasses = classMap({
204       "height-limited": this.windows.length > 3,
205       "width-limited": this.windows.length > 1,
206     });
207     let cardCount;
208     if (this.windows.length <= 1) {
209       cardCount = "one";
210     } else if (this.windows.length === 2) {
211       cardCount = "two";
212     } else {
213       cardCount = "three-or-more";
214     }
215     return html`
216       <link
217         rel="stylesheet"
218         href="chrome://browser/content/firefoxview/view-opentabs.css"
219       />
220       <link
221         rel="stylesheet"
222         href="chrome://browser/content/firefoxview/firefoxview.css"
223       />
224       <div class="sticky-container bottom-fade">
225         <h2 class="page-header" data-l10n-id="firefoxview-opentabs-header"></h2>
226         <div class="open-tabs-options">
227           <div>
228             <fxview-search-textbox
229               data-l10n-id="firefoxview-search-text-box-opentabs"
230               data-l10n-attrs="placeholder"
231               @fxview-search-textbox-query=${this.onSearchQuery}
232               .size=${this.searchTextboxSize}
233               pageName=${this.recentBrowsing ? "recentbrowsing" : "opentabs"}
234             ></fxview-search-textbox>
235           </div>
236           <div class="open-tabs-sort-wrapper">
237             <div class="open-tabs-sort-option">
238               <input
239                 type="radio"
240                 id="sort-by-recency"
241                 name="open-tabs-sort-option"
242                 value="recency"
243                 ?checked=${this.sortOption === "recency"}
244                 @click=${this.onChangeSortOption}
245               />
246               <label
247                 for="sort-by-recency"
248                 data-l10n-id="firefoxview-sort-open-tabs-by-recency-label"
249               ></label>
250             </div>
251             <div class="open-tabs-sort-option">
252               <input
253                 type="radio"
254                 id="sort-by-order"
255                 name="open-tabs-sort-option"
256                 value="tabStripOrder"
257                 ?checked=${this.sortOption === "tabStripOrder"}
258                 @click=${this.onChangeSortOption}
259               />
260               <label
261                 for="sort-by-order"
262                 data-l10n-id="firefoxview-sort-open-tabs-by-order-label"
263               ></label>
264             </div>
265           </div>
266         </div>
267       </div>
268       <div
269         card-count=${cardCount}
270         class="view-opentabs-card-container cards-container"
271       >
272         ${when(
273           currentWindowIndex && currentWindowTabs,
274           () => html`
275             <view-opentabs-card
276               class=${cardClasses}
277               .tabs=${currentWindowTabs}
278               .paused=${this.paused}
279               data-inner-id="${this.currentWindow.windowGlobalChild
280                 .innerWindowId}"
281               data-l10n-id="firefoxview-opentabs-current-window-header"
282               data-l10n-args="${JSON.stringify({
283                 winID: currentWindowIndex,
284               })}"
285               .searchQuery=${this.searchQuery}
286               .bookmarkList=${this.bookmarkList}
287             ></view-opentabs-card>
288           `
289         )}
290         ${map(
291           otherWindows,
292           ([winID, tabs, win]) => html`
293             <view-opentabs-card
294               class=${cardClasses}
295               .tabs=${tabs}
296               .paused=${this.paused}
297               data-inner-id="${win.windowGlobalChild.innerWindowId}"
298               data-l10n-id="firefoxview-opentabs-window-header"
299               data-l10n-args="${JSON.stringify({ winID })}"
300               .searchQuery=${this.searchQuery}
301               .bookmarkList=${this.bookmarkList}
302             ></view-opentabs-card>
303           `
304         )}
305       </div>
306     `;
307   }
309   onSearchQuery(e) {
310     this.searchQuery = e.detail.query;
311   }
313   onChangeSortOption(e) {
314     this.sortOption = e.target.value;
315     this.#setupTabChangeListener();
316     if (!this.recentBrowsing) {
317       Services.prefs.setStringPref(
318         "browser.tabs.firefox-view.ui-state.opentabs.sort-option",
319         this.sortOption
320       );
321     }
322   }
324   /**
325    * Render a template for the 'Recent browsing' page, which shows a shorter list of
326    * open tabs in the current window.
327    *
328    * @returns {TemplateResult}
329    *   The recent browsing template.
330    */
331   getRecentBrowsingTemplate() {
332     const tabs = this.openTabsTarget.getRecentTabs();
333     return html`<view-opentabs-card
334       .tabs=${tabs}
335       .recentBrowsing=${true}
336       .paused=${this.paused}
337       .searchQuery=${this.searchQuery}
338       .bookmarkList=${this.bookmarkList}
339     ></view-opentabs-card>`;
340   }
342   handleEvent({ detail, type }) {
343     if (this.recentBrowsing && type === "fxview-search-textbox-query") {
344       this.onSearchQuery({ detail });
345       return;
346     }
347     let windowIds;
348     switch (type) {
349       case "TabRecencyChange":
350       case "TabChange":
351         windowIds = detail.windowIds;
352         this._updateWindowList();
353         this.bookmarkList.setTrackedUrls(this.#getAllTabUrls());
354         break;
355     }
356     if (this.recentBrowsing) {
357       return;
358     }
359     if (windowIds?.length) {
360       // there were tab changes to one or more windows
361       for (let winId of windowIds) {
362         const cardForWin = this.shadowRoot.querySelector(
363           `view-opentabs-card[data-inner-id="${winId}"]`
364         );
365         if (this.searchQuery) {
366           cardForWin?.updateSearchResults();
367         }
368         cardForWin?.requestUpdate();
369       }
370     } else {
371       let winId = window.windowGlobalChild.innerWindowId;
372       let cardForWin = this.shadowRoot.querySelector(
373         `view-opentabs-card[data-inner-id="${winId}"]`
374       );
375       if (this.searchQuery) {
376         cardForWin?.updateSearchResults();
377       }
378     }
379   }
381   async _updateWindowList() {
382     this.windows = this.openTabsTarget.currentWindows;
383   }
385 customElements.define("view-opentabs", OpenTabsInView);
388  * A card which displays a list of open tabs for a window.
390  * @property {boolean} showMore
391  *   Whether to force all tabs to be shown, regardless of available space.
392  * @property {MozTabbrowserTab[]} tabs
393  *   The open tabs to show.
394  * @property {string} title
395  *   The window title.
396  */
397 class OpenTabsInViewCard extends ViewPageContent {
398   static properties = {
399     showMore: { type: Boolean },
400     tabs: { type: Array },
401     title: { type: String },
402     recentBrowsing: { type: Boolean },
403     searchQuery: { type: String },
404     searchResults: { type: Array },
405     showAll: { type: Boolean },
406     cumulativeSearches: { type: Number },
407     bookmarkList: { type: Object },
408   };
409   static MAX_TABS_FOR_COMPACT_HEIGHT = 7;
411   constructor() {
412     super();
413     this.showMore = false;
414     this.tabs = [];
415     this.title = "";
416     this.recentBrowsing = false;
417     this.devices = [];
418     this.searchQuery = "";
419     this.searchResults = null;
420     this.showAll = false;
421     this.cumulativeSearches = 0;
422   }
424   static queries = {
425     cardEl: "card-container",
426     tabContextMenu: "view-opentabs-contextmenu",
427     tabList: "opentabs-tab-list",
428   };
430   openContextMenu(e) {
431     let { originalEvent } = e.detail;
432     this.tabContextMenu.toggle({
433       triggerNode: e.originalTarget,
434       originalEvent,
435     });
436   }
438   getMaxTabsLength() {
439     if (this.recentBrowsing && !this.showAll) {
440       return MAX_TABS_FOR_RECENT_BROWSING;
441     } else if (this.classList.contains("height-limited") && !this.showMore) {
442       return OpenTabsInViewCard.MAX_TABS_FOR_COMPACT_HEIGHT;
443     }
444     return -1;
445   }
447   isShowAllLinkVisible() {
448     return (
449       this.recentBrowsing &&
450       this.searchQuery &&
451       this.searchResults.length > MAX_TABS_FOR_RECENT_BROWSING &&
452       !this.showAll
453     );
454   }
456   toggleShowMore(event) {
457     if (
458       event.type == "click" ||
459       (event.type == "keydown" && event.code == "Enter") ||
460       (event.type == "keydown" && event.code == "Space")
461     ) {
462       event.preventDefault();
463       this.showMore = !this.showMore;
464     }
465   }
467   enableShowAll(event) {
468     if (
469       event.type == "click" ||
470       (event.type == "keydown" && event.code == "Enter") ||
471       (event.type == "keydown" && event.code == "Space")
472     ) {
473       event.preventDefault();
474       Glean.firefoxviewNext.searchShowAllShowallbutton.record({
475         section: "opentabs",
476       });
477       this.showAll = true;
478     }
479   }
481   onTabListRowClick(event) {
482     // Don't open pinned tab if mute/unmute indicator button selected
483     if (
484       Array.from(event.explicitOriginalTarget.classList).includes(
485         "fxview-tab-row-pinned-media-button"
486       )
487     ) {
488       return;
489     }
490     const tab = event.originalTarget.tabElement;
491     const browserWindow = tab.ownerGlobal;
492     browserWindow.focus();
493     browserWindow.gBrowser.selectedTab = tab;
495     Glean.firefoxviewNext.openTabTabs.record({
496       page: this.recentBrowsing ? "recentbrowsing" : "opentabs",
497       window: this.title || "Window 1 (Current)",
498     });
499     if (this.searchQuery) {
500       Services.telemetry
501         .getKeyedHistogramById("FIREFOX_VIEW_CUMULATIVE_SEARCHES")
502         .add(
503           this.recentBrowsing ? "recentbrowsing" : "opentabs",
504           this.cumulativeSearches
505         );
506       this.cumulativeSearches = 0;
507     }
508   }
510   closeTab(event) {
511     const tab = event.originalTarget.tabElement;
512     tab?.ownerGlobal.gBrowser.removeTab(tab);
514     Glean.firefoxviewNext.closeOpenTabTabs.record();
515   }
517   viewVisibleCallback() {
518     this.getRootNode().host.toggleVisibilityInCardContainer(true);
519   }
521   viewHiddenCallback() {
522     this.getRootNode().host.toggleVisibilityInCardContainer(true);
523   }
525   firstUpdated() {
526     this.getRootNode().host.toggleVisibilityInCardContainer(true);
527   }
529   render() {
530     return html`
531       <link
532         rel="stylesheet"
533         href="chrome://browser/content/firefoxview/firefoxview.css"
534       />
535       <card-container
536         ?preserveCollapseState=${this.recentBrowsing}
537         shortPageName=${this.recentBrowsing ? "opentabs" : null}
538         ?showViewAll=${this.recentBrowsing}
539         ?removeBlockEndMargin=${!this.recentBrowsing}
540       >
541         ${when(
542           this.recentBrowsing,
543           () =>
544             html`<h3
545               slot="header"
546               data-l10n-id="firefoxview-opentabs-header"
547             ></h3>`,
548           () => html`<h3 slot="header">${this.title}</h3>`
549         )}
550         <div class="fxview-tab-list-container" slot="main">
551           <opentabs-tab-list
552             .hasPopup=${"menu"}
553             ?compactRows=${this.classList.contains("width-limited")}
554             @fxview-tab-list-primary-action=${this.onTabListRowClick}
555             @fxview-tab-list-secondary-action=${this.openContextMenu}
556             @fxview-tab-list-tertiary-action=${this.closeTab}
557             secondaryActionClass="options-button"
558             tertiaryActionClass="dismiss-button"
559             .maxTabsLength=${this.getMaxTabsLength()}
560             .tabItems=${this.searchResults ||
561             getTabListItems(this.tabs, this.recentBrowsing)}
562             .searchQuery=${this.searchQuery}
563             .pinnedTabsGridView=${!this.recentBrowsing}
564             ><view-opentabs-contextmenu slot="menu"></view-opentabs-contextmenu>
565           </opentabs-tab-list>
566         </div>
567         ${when(
568           this.recentBrowsing,
569           () =>
570             html` <div
571               @click=${this.enableShowAll}
572               @keydown=${this.enableShowAll}
573               data-l10n-id="firefoxview-show-all"
574               ?hidden=${!this.isShowAllLinkVisible()}
575               slot="footer"
576               tabindex="0"
577               role="link"
578             ></div>`,
579           () =>
580             html` <div
581               @click=${this.toggleShowMore}
582               @keydown=${this.toggleShowMore}
583               data-l10n-id="${this.showMore
584                 ? "firefoxview-show-less"
585                 : "firefoxview-show-more"}"
586               ?hidden=${!this.classList.contains("height-limited") ||
587               this.tabs.length <=
588                 OpenTabsInViewCard.MAX_TABS_FOR_COMPACT_HEIGHT}
589               slot="footer"
590               tabindex="0"
591               role="link"
592             ></div>`
593         )}
594       </card-container>
595     `;
596   }
598   willUpdate(changedProperties) {
599     if (changedProperties.has("searchQuery")) {
600       this.showAll = false;
601       this.cumulativeSearches = this.searchQuery
602         ? this.cumulativeSearches + 1
603         : 0;
604     }
605     if (changedProperties.has("searchQuery") || changedProperties.has("tabs")) {
606       this.updateSearchResults();
607     }
608   }
610   updateSearchResults() {
611     this.searchResults = this.searchQuery
612       ? searchTabList(this.searchQuery, getTabListItems(this.tabs))
613       : null;
614   }
616   updated() {
617     this.updateBookmarkStars();
618   }
620   async updateBookmarkStars() {
621     const tabItems = [...this.tabList.tabItems];
622     for (const row of tabItems) {
623       const isBookmark = await this.bookmarkList.isBookmark(row.url);
624       if (isBookmark && !row.indicators.includes("bookmark")) {
625         row.indicators.push("bookmark");
626       }
627       if (!isBookmark && row.indicators.includes("bookmark")) {
628         row.indicators = row.indicators.filter(i => i !== "bookmark");
629       }
630       row.primaryL10nId = getPrimaryL10nId(
631         this.isRecentBrowsing,
632         row.indicators
633       );
634     }
635     this.tabList.tabItems = tabItems;
636   }
638 customElements.define("view-opentabs-card", OpenTabsInViewCard);
641  * A context menu of actions available for open tab list items.
642  */
643 class OpenTabsContextMenu extends MozLitElement {
644   static properties = {
645     devices: { type: Array },
646     triggerNode: { hasChanged: () => true, type: Object },
647   };
649   static queries = {
650     panelList: "panel-list",
651   };
653   constructor() {
654     super();
655     this.triggerNode = null;
656     this.boundObserve = (...args) => this.observe(...args);
657     this.devices = [];
658   }
660   get logger() {
661     return getLogger("OpenTabsContextMenu");
662   }
664   get ownerViewPage() {
665     return this.ownerDocument.querySelector("view-opentabs");
666   }
668   connectedCallback() {
669     super.connectedCallback();
670     this.fetchDevicesPromise = this.fetchDevices();
671     Services.obs.addObserver(this.boundObserve, TOPIC_DEVICELIST_UPDATED);
672     Services.obs.addObserver(this.boundObserve, TOPIC_DEVICESTATE_CHANGED);
673   }
675   disconnectedCallback() {
676     super.disconnectedCallback();
677     Services.obs.removeObserver(this.boundObserve, TOPIC_DEVICELIST_UPDATED);
678     Services.obs.removeObserver(this.boundObserve, TOPIC_DEVICESTATE_CHANGED);
679   }
681   observe(_subject, topic, _data) {
682     if (
683       topic == TOPIC_DEVICELIST_UPDATED ||
684       topic == TOPIC_DEVICESTATE_CHANGED
685     ) {
686       this.fetchDevicesPromise = this.fetchDevices();
687     }
688   }
690   async fetchDevices() {
691     const currentWindow = this.ownerViewPage.getWindow();
692     if (currentWindow?.gSync) {
693       try {
694         await lazy.fxAccounts.device.refreshDeviceList();
695       } catch (e) {
696         this.logger.warn("Could not refresh the FxA device list", e);
697       }
698       this.devices = currentWindow.gSync.getSendTabTargets();
699     }
700   }
702   async toggle({ triggerNode, originalEvent }) {
703     if (this.panelList?.open) {
704       // the menu will close so avoid all the other work to update its contents
705       this.panelList.toggle(originalEvent);
706       return;
707     }
708     this.triggerNode = triggerNode;
709     await this.fetchDevicesPromise;
710     await this.getUpdateComplete();
711     this.panelList.toggle(originalEvent);
712   }
714   copyLink(e) {
715     placeLinkOnClipboard(this.triggerNode.title, this.triggerNode.url);
716     this.ownerViewPage.recordContextMenuTelemetry("copy-link", e);
717   }
719   closeTab(e) {
720     const tab = this.triggerNode.tabElement;
721     tab?.ownerGlobal.gBrowser.removeTab(tab);
722     this.ownerViewPage.recordContextMenuTelemetry("close-tab", e);
723   }
725   pinTab(e) {
726     const tab = this.triggerNode.tabElement;
727     tab?.ownerGlobal.gBrowser.pinTab(tab);
728     this.ownerViewPage.recordContextMenuTelemetry("pin-tab", e);
729   }
731   unpinTab(e) {
732     const tab = this.triggerNode.tabElement;
733     tab?.ownerGlobal.gBrowser.unpinTab(tab);
734     this.ownerViewPage.recordContextMenuTelemetry("unpin-tab", e);
735   }
737   toggleAudio(e) {
738     const tab = this.triggerNode.tabElement;
739     tab.toggleMuteAudio();
740     this.ownerViewPage.recordContextMenuTelemetry(
741       `${
742         this.triggerNode.indicators.includes("muted") ? "unmute" : "mute"
743       }-tab`,
744       e
745     );
746   }
748   moveTabsToStart(e) {
749     const tab = this.triggerNode.tabElement;
750     tab?.ownerGlobal.gBrowser.moveTabsToStart(tab);
751     this.ownerViewPage.recordContextMenuTelemetry("move-tab-start", e);
752   }
754   moveTabsToEnd(e) {
755     const tab = this.triggerNode.tabElement;
756     tab?.ownerGlobal.gBrowser.moveTabsToEnd(tab);
757     this.ownerViewPage.recordContextMenuTelemetry("move-tab-end", e);
758   }
760   moveTabsToWindow(e) {
761     const tab = this.triggerNode.tabElement;
762     tab?.ownerGlobal.gBrowser.replaceTabsWithWindow(tab);
763     this.ownerViewPage.recordContextMenuTelemetry("move-tab-window", e);
764   }
766   moveMenuTemplate() {
767     const tab = this.triggerNode?.tabElement;
768     if (!tab) {
769       return null;
770     }
771     const browserWindow = tab.ownerGlobal;
772     const tabs = browserWindow?.gBrowser.visibleTabs || [];
773     const position = tabs.indexOf(tab);
775     return html`
776       <panel-list slot="submenu" id="move-tab-menu">
777         ${position > 0
778           ? html`<panel-item
779               @click=${this.moveTabsToStart}
780               data-l10n-id="fxviewtabrow-move-tab-start"
781               data-l10n-attrs="accesskey"
782             ></panel-item>`
783           : null}
784         ${position < tabs.length - 1
785           ? html`<panel-item
786               @click=${this.moveTabsToEnd}
787               data-l10n-id="fxviewtabrow-move-tab-end"
788               data-l10n-attrs="accesskey"
789             ></panel-item>`
790           : null}
791         <panel-item
792           @click=${this.moveTabsToWindow}
793           data-l10n-id="fxviewtabrow-move-tab-window"
794           data-l10n-attrs="accesskey"
795         ></panel-item>
796       </panel-list>
797     `;
798   }
800   async sendTabToDevice(e) {
801     let deviceId = e.target.getAttribute("device-id");
802     let device = this.devices.find(dev => dev.id == deviceId);
803     const viewPage = this.ownerViewPage;
804     viewPage.recordContextMenuTelemetry("send-tab-device", e);
806     if (device && this.triggerNode) {
807       await viewPage
808         .getWindow()
809         .gSync.sendTabToDevice(
810           this.triggerNode.url,
811           [device],
812           this.triggerNode.title
813         );
814     }
815   }
817   sendTabTemplate() {
818     return html` <panel-list slot="submenu" id="send-tab-menu">
819       ${this.devices.map(device => {
820         return html`
821           <panel-item @click=${this.sendTabToDevice} device-id=${device.id}
822             >${device.name}</panel-item
823           >
824         `;
825       })}
826     </panel-list>`;
827   }
829   render() {
830     const tab = this.triggerNode?.tabElement;
831     if (!tab) {
832       return null;
833     }
835     return html`
836       <link
837         rel="stylesheet"
838         href="chrome://browser/content/firefoxview/firefoxview.css"
839       />
840       <panel-list data-tab-type="opentabs">
841         <panel-item
842           data-l10n-id="fxviewtabrow-move-tab"
843           data-l10n-attrs="accesskey"
844           submenu="move-tab-menu"
845           >${this.moveMenuTemplate()}</panel-item
846         >
847         <panel-item
848           data-l10n-id=${tab.pinned
849             ? "fxviewtabrow-unpin-tab"
850             : "fxviewtabrow-pin-tab"}
851           data-l10n-attrs="accesskey"
852           @click=${tab.pinned ? this.unpinTab : this.pinTab}
853         ></panel-item>
854         <panel-item
855           data-l10n-id=${tab.hasAttribute("muted")
856             ? "fxviewtabrow-unmute-tab"
857             : "fxviewtabrow-mute-tab"}
858           data-l10n-attrs="accesskey"
859           @click=${this.toggleAudio}
860         ></panel-item>
861         <hr />
862         <panel-item
863           data-l10n-id="fxviewtabrow-copy-link"
864           data-l10n-attrs="accesskey"
865           @click=${this.copyLink}
866         ></panel-item>
867         ${this.devices.length >= 1
868           ? html`<panel-item
869               data-l10n-id="fxviewtabrow-send-tab"
870               data-l10n-attrs="accesskey"
871               submenu="send-tab-menu"
872               >${this.sendTabTemplate()}</panel-item
873             >`
874           : null}
875       </panel-list>
876     `;
877   }
879 customElements.define("view-opentabs-contextmenu", OpenTabsContextMenu);
882  * Checks if a given tab is within a container (contextual identity)
884  * @param {MozTabbrowserTab[]} tab
885  *   Tab to fetch container info on.
886  * @returns {object[]}
887  *   Container object.
888  */
889 function getContainerObj(tab) {
890   let userContextId = tab.getAttribute("usercontextid");
891   let containerObj = null;
892   if (userContextId) {
893     containerObj =
894       lazy.ContextualIdentityService.getPublicIdentityFromId(userContextId);
895   }
896   return containerObj;
900  * Gets an array of tab indicators (if any) when normalizing for fxview-tab-list
902  * @param {MozTabbrowserTab[]} tab
903  *   Tab to fetch container info on.
904  * @returns {Array[]}
905  *  Array of named tab indicators
906  */
907 function getIndicatorsForTab(tab) {
908   const url = tab.linkedBrowser?.currentURI?.spec || "";
909   let tabIndicators = [];
910   let hasAttention =
911     (tab.pinned &&
912       (tab.hasAttribute("attention") || tab.hasAttribute("titlechanged"))) ||
913     (!tab.pinned && tab.hasAttribute("attention"));
915   if (tab.pinned) {
916     tabIndicators.push("pinned");
917   }
918   if (getContainerObj(tab)) {
919     tabIndicators.push("container");
920   }
921   if (hasAttention) {
922     tabIndicators.push("attention");
923   }
924   if (tab.hasAttribute("soundplaying") && !tab.hasAttribute("muted")) {
925     tabIndicators.push("soundplaying");
926   }
927   if (tab.hasAttribute("muted")) {
928     tabIndicators.push("muted");
929   }
930   if (checkIfPinnedNewTab(url)) {
931     tabIndicators.push("pinnedOnNewTab");
932   }
934   return tabIndicators;
937  * Gets the primary l10n id for a tab when normalizing for fxview-tab-list
939  * @param {boolean} isRecentBrowsing
940  *   Whether the tabs are going to be displayed on the Recent Browsing page or not
941  * @param {Array[]} tabIndicators
942  *   Array of tab indicators for the given tab
943  * @returns {string}
944  *  L10n ID string
945  */
946 function getPrimaryL10nId(isRecentBrowsing, tabIndicators) {
947   let indicatorL10nId = null;
948   if (!isRecentBrowsing) {
949     if (
950       tabIndicators?.includes("pinned") &&
951       tabIndicators?.includes("bookmark")
952     ) {
953       indicatorL10nId = "firefoxview-opentabs-bookmarked-pinned-tab";
954     } else if (tabIndicators?.includes("pinned")) {
955       indicatorL10nId = "firefoxview-opentabs-pinned-tab";
956     } else if (tabIndicators?.includes("bookmark")) {
957       indicatorL10nId = "firefoxview-opentabs-bookmarked-tab";
958     }
959   }
960   return indicatorL10nId;
964  * Gets the primary l10n args for a tab when normalizing for fxview-tab-list
966  * @param {MozTabbrowserTab[]} tab
967  *   Tab to fetch container info on.
968  * @param {boolean} isRecentBrowsing
969  *   Whether the tabs are going to be displayed on the Recent Browsing page or not
970  * @param {string} url
971  *   URL for the given tab
972  * @returns {string}
973  *  L10n ID args
974  */
975 function getPrimaryL10nArgs(tab, isRecentBrowsing, url) {
976   return JSON.stringify({ tabTitle: tab.label, url });
980  * Check if a given url is pinned on the new tab page
982  * @param {string} url
983  *   url to check
984  * @returns {boolean}
985  *   is tabbed pinned on new tab page
986  */
987 function checkIfPinnedNewTab(url) {
988   return url && lazy.NewTabUtils.pinnedLinks.isPinned({ url });
992  * Convert a list of tabs into the format expected by the fxview-tab-list
993  * component.
995  * @param {MozTabbrowserTab[]} tabs
996  *   Tabs to format.
997  * @param {boolean} isRecentBrowsing
998  *   Whether the tabs are going to be displayed on the Recent Browsing page or not
999  * @returns {object[]}
1000  *   Formatted objects.
1001  */
1002 function getTabListItems(tabs, isRecentBrowsing) {
1003   let filtered = tabs?.filter(tab => !tab.closing && !tab.hidden);
1005   return filtered.map(tab => {
1006     let tabIndicators = getIndicatorsForTab(tab);
1007     let containerObj = getContainerObj(tab);
1008     const url = tab?.linkedBrowser?.currentURI?.spec || "";
1009     return {
1010       containerObj,
1011       indicators: tabIndicators,
1012       icon: tab.getAttribute("image"),
1013       primaryL10nId: getPrimaryL10nId(isRecentBrowsing, tabIndicators),
1014       primaryL10nArgs: getPrimaryL10nArgs(tab, isRecentBrowsing, url),
1015       secondaryL10nId:
1016         isRecentBrowsing || (!isRecentBrowsing && !tab.pinned)
1017           ? "fxviewtabrow-options-menu-button"
1018           : null,
1019       secondaryL10nArgs:
1020         isRecentBrowsing || (!isRecentBrowsing && !tab.pinned)
1021           ? JSON.stringify({ tabTitle: tab.label })
1022           : null,
1023       tertiaryL10nId:
1024         isRecentBrowsing || (!isRecentBrowsing && !tab.pinned)
1025           ? "fxviewtabrow-close-tab-button"
1026           : null,
1027       tertiaryL10nArgs:
1028         isRecentBrowsing || (!isRecentBrowsing && !tab.pinned)
1029           ? JSON.stringify({ tabTitle: tab.label })
1030           : null,
1031       tabElement: tab,
1032       time: tab.lastSeenActive,
1033       title: tab.label,
1034       url,
1035     };
1036   });